@stacksjs/security 0.75.398 → 0.75.399

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 (2) hide show
  1. package/dist/scan.js +11 -11
  2. package/package.json +5 -5
package/dist/scan.js CHANGED
@@ -1,12 +1,12 @@
1
1
  // @bun
2
- import{readdir as ue,stat as de}from"fs/promises";import{join as T,relative as pe}from"path";var J=new Set([".git","node_modules","dist","build",".next",".turbo","coverage",".zig-cache","zig-cache","pantry",".cache"]),fe=new Set(["png","jpg","jpeg","gif","webp","avif","ico","icns","bmp","tiff","heic","mp3","mp4","mov","webm","m4a","wav","ogg","flac","aac","woff","woff2","ttf","otf","eot","pdf","zip","gz","tgz","br","zst","xz","7z","tar","wasm","so","dylib","a","o","node","db","sqlite","sqlite3","mdb","psd","ai","sketch","fig","car","nib","riv","lottie","glb","gltf","usdz","fit","bin","dat","map"]),ge=/(^|\/)(bun\.lockb?|package-lock\.json|yarn\.lock|pnpm-lock\.yaml|pantry\.lock|Cargo\.lock|Package\.resolved|Podfile\.lock)$|\.min\.(js|css)$|\.snap$/,Q=1e6;function P(e){if(ge.test(e))return!1;let s=e.split(".").pop()?.toLowerCase()??"";return!fe.has(s)}function x(e){return/(^|\/)(tests?|__tests__|__mocks__|fixtures?|examples?|e2e|mocks?|stubs?|seeders?|bench|benchmarks?)\//.test(e)||/\.(test|spec)\.[cm]?[jt]sx?$/.test(e)}function F(e){return/\.(md|mdx|txt|rst)$/i.test(e)||/(^|\/)docs?\//.test(e)}async function X(e,s,t){let n=await ue(s,{withFileTypes:!0}).catch(()=>[]);for(let o of n)if(o.isDirectory()){if(!J.has(o.name))await X(e,T(s,o.name),t)}else if(o.isFile())t.push(pe(e,T(s,o.name)))}async function L(e){try{let t=Bun.spawn(["git","ls-files","-z","--cached","--others","--exclude-standard"],{cwd:e,stdout:"pipe",stderr:"ignore"}),[n,o]=await Promise.all([new Response(t.stdout).text(),t.exited]);if(o===0){let a=[];for(let i of n.split("\x00")){if(!i||i.split("/").some((r)=>J.has(r)))continue;if((await de(T(e,i)).catch(()=>null))?.isFile())a.push(i)}return{files:[...new Set(a)].sort(),tracked:!0}}}catch{}let s=[];return await X(e,e,s),{files:s.sort(),tracked:!1}}var he=/\bparam\w*\(|\bplaceholders?\b|\bquote\w*\(|\bescape\w*\(|\bident\w*\(|^(?:q|qi|\d+)$|^[a-z]{1,2}\(\d+\)$|^\w*(?:Placeholders?|Quoted|Columns?|Table(?:Name)?|Qualified|Sql|Clause|Cascade)\w*$/i;function me(e){let s=[...e.matchAll(/\$\{([^}]*)\}/g)].map((t)=>(t[1]??"").trim());return s.length>0&&s.every((t)=>he.test(t))}function ye(e){let s=e.split(/\.(?:innerHTML|outerHTML)\s*\+?=(?!=)/)[1];if(s===void 0)return!0;return!/^\s*(['"`])(?:(?!\$\{)[^'"`\\])*\1\s*;?\s*(?:\/\/.*)?$/.test(s)}var y=/\.(?:[cm]?[jt]sx?|stx|vue|svelte|html)$/i,ke=[{rule:"code/eval",title:"eval() or new Function() runs a string as code",pattern:/(?<![.\w$])(?:eval\s*\(|new\s+Function\s*\()/,severity:"high",fix:"Parse data with JSON.parse or a real parser, and pick behaviour from a lookup table instead of building code.",skipTests:!0,ext:y},{rule:"code/command-injection",title:"Shell command built from a string with interpolation",pattern:/(?<![.\w$])(?:exec|execSync)\s*\(\s*(?:`[^`]*\$\{|(?:'[^']*'|"[^"]*")\s*\+)|\b(?:child_process|cp|childProcess)\.(?:exec|execSync)\s*\(\s*(?:`[^`]*\$\{|(?:'[^']*'|"[^"]*")\s*\+)|\bspawn(?:Sync)?\s*\(\s*['"`](?:ba|z|da)?sh['"`]\s*,\s*\[\s*['"`]-c['"`]\s*,\s*(?:`[^`]*\$\{|[A-Za-z_$])/,severity:"high",fix:"Pass the program and its arguments separately (Bun.spawn([cmd, ...args]) or Bun's $ template), never one string.",skipTests:!0,ext:y},{rule:"code/sql-interpolation",title:"SQL built with string interpolation",pattern:/(?:\.unsafe|\bsql\.raw|\.\$queryRawUnsafe|\.executeRaw)\s*\(\s*(?:`[^`]*\$\{|(?:'[^']*'|"[^"]*")\s*\+)/,severity:"medium",fix:"Use parameter placeholders ($1, ?) and pass the values separately; interpolate only fixed identifiers (a table or column from a list in your code).",skipTests:!0,ext:y,accept:(e)=>!me(e)},{rule:"code/insecure-tls",title:"TLS certificate verification is turned off",pattern:/rejectUnauthorized\s*:\s*false|NODE_TLS_REJECT_UNAUTHORIZED\s*[=:]\s*['"`]?0|tls\s*:\s*\{[^}]*rejectUnauthorized\s*:\s*false/,severity:"high",fix:"Trust the real certificate (or add the CA) instead of disabling verification.",skipTests:!0,ext:y},{rule:"code/jwt-none",title:'A token algorithm of "none" skips signature checks',pattern:/\balg(?:orithms?)?\s*:\s*\[?\s*['"`]none['"`]/i,severity:"high",fix:"Pin the expected algorithm when verifying a token.",skipTests:!0,ext:y},{rule:"code/inner-html",title:"Markup written straight into the page",pattern:/\.(?:innerHTML|outerHTML)\s*[+]?=(?!=)|\binsertAdjacentHTML\s*\(|\bdocument\.write(?:ln)?\s*\(/,severity:"medium",fix:"Set textContent, or render through the template engine, which escapes by default. If the HTML is sanitized, say so with a security-ignore comment.",skipTests:!0,ext:y,accept:(e)=>/insertAdjacentHTML|document\.write/.test(e)||ye(e)},{rule:"code/random-token",title:"Math.random() used for something secret",pattern:/Math\.random\(\)[^\n]*\b(?:token|secret|password|nonce|otp|csrf|api.?key)\b|\b(?:token|secret|password|nonce|otp|csrf|api.?key)\b[^\n]*Math\.random\(\)/i,severity:"medium",fix:"Use crypto.getRandomValues() or crypto.randomUUID().",skipTests:!0,ext:y,accept:(e)=>!/setTimeout|setInterval|delay|jitter|backoff|retry|sleep/i.test(e)},{rule:"code/weak-hash",title:"MD5 or SHA-1 used as a hash",pattern:/createHash\(\s*['"`](?:md5|sha1)['"`]\s*\)/i,severity:"low",fix:"Use SHA-256 or better; for passwords use the password hasher (make/check). Fine for a non-security cache key or a vendor's required format: say so with a security-ignore comment.",skipTests:!0,ext:y},{rule:"code/cors-wildcard-credentials",title:"CORS allows every origin with credentials",pattern:/Access-Control-Allow-Origin['"`]?\s*[:,]\s*['"`]\*['"`][^\n]*(?:credentials|Allow-Credentials)|(?:credentials|Allow-Credentials)[^\n]*Access-Control-Allow-Origin['"`]?\s*[:,]\s*['"`]\*['"`]/i,severity:"medium",fix:"Name the allowed origins; browsers refuse a wildcard with credentials anyway.",skipTests:!0,ext:y},{rule:"code/disabled-csrf",title:"CSRF protection turned off",pattern:/\bcsrf\s*:\s*false\b|verifyCsrf\s*=\s*false/i,severity:"medium",fix:"Leave CSRF protection on for anything a browser session can reach.",skipTests:!0,ext:y}],ve=/security-ignore(?::|\s|$)\s*([a-z][\w/-]*)?\s*(.*)$/i;function O(e,s){for(let t of[e[s],e[s-1]]){if(t===void 0)continue;let n=ve.exec(t);if(n){let o=n[1]?.includes("/")?n[1]:null,a=(o?n[2]??"":`${n[1]??""} ${n[2]??""}`).replace(/\s*(?:-->|\*\/)\s*$/,"").trim();return{rule:o,reason:a}}}return null}function we(e){return/^\s*(?:\/\/|\*|\/\*|#|<!--)/.test(e)}function j(e,s){let t=[],n=x(e),o=ke.filter((i)=>(!i.skipTests||!n)&&(!i.ext||i.ext.test(e)));if(!o.length)return t;let a=s.split(`
3
- `);for(let i=0;i<a.length;i++){let c=a[i];if(c.length>2000||we(c))continue;for(let r of o){if(!r.pattern.test(c)||r.accept&&!r.accept(c))continue;t.push({rule:r.rule,check:"code",severity:r.severity,title:r.title,file:e,line:i+1,detail:c.trim().slice(0,160),fix:r.fix})}}return t}import{existsSync as ee}from"fs";import{join as te}from"path";import ne from"process";var xe={critical:"critical",high:"high",moderate:"medium",medium:"medium",low:"low"};function se(e){return e?.match(/GHSA-[\w-]+/i)?.[0]??e}function ie(e){let s;try{s=JSON.parse(e)}catch{return null}if(!s||typeof s!=="object"||Array.isArray(s))return null;let t=[];for(let[o,a]of Object.entries(s)){if(!Array.isArray(a))continue;for(let i of a){let c=xe[String(i.severity??"").toLowerCase()]??"medium",r=i.vulnerable_versions?` (${i.vulnerable_versions})`:"";t.push({rule:"dependency/advisory",check:"dependencies",severity:c,title:i.title||`Known vulnerability in ${o}`,package:o,detail:`${o}${r}${i.cvss?.score?`, CVSS ${i.cvss.score}`:""}`,reference:se(i.url),fix:`Raise ${o} outside ${i.vulnerable_versions||"the affected range"}: update the package that depends on it (bun update ${o} --recursive) so the lockfile resolves a fixed version. Do not pin it with overrides.`})}}let n=(o)=>["critical","high","medium","low"].indexOf(o);return t.sort((o,a)=>n(o.severity)-n(a.severity)||(o.package??"").localeCompare(a.package??""))}async function z(e,s=60000){let t=(n)=>({skipped:{check:"dependencies",reason:n}});if(!ee(te(e,"bun.lock"))&&!ee(te(e,"bun.lockb")))return t("no bun.lock to audit");try{let n=Bun.spawn([ne.execPath,"audit","--json"],{cwd:e,stdout:"pipe",stderr:"pipe",env:{...ne.env,NO_COLOR:"1"}}),o=setTimeout(()=>n.kill(),s),[a,i,c]=await Promise.all([new Response(n.stdout).text(),new Response(n.stderr).text(),n.exited]);clearTimeout(o);let r=ie(a.trim()||"{}");if(r)return{findings:r};let l=(i||a).trim().split(`
4
- `).find((d)=>d.trim())??`exited ${c}`;return t(`bun audit gave no report (${l.slice(0,160)})`)}catch(n){return t(`bun audit could not run (${n instanceof Error?n.message:String(n)})`)}}var b=["critical","high","medium","low"],A=["dependencies","secrets","code","workflows","hygiene"];function N(e){return b.indexOf(e)}function D(e,s){return N(e)<=N(s)}var re={critical:"\x1B[1;31m",high:"\x1B[31m",medium:"\x1B[33m",low:"\x1B[2m"},be="\x1B[0m",k="\x1B[2m",Se="\x1B[1m",$e="\x1B[32m";function _e(e,s){if(s==="none")return!1;return e.findings.some((t)=>D(t.severity,s))||e.ignoreProblems.length>0}function oe(e){let s={critical:0,high:0,medium:0,low:0};for(let t of e)s[t.severity]++;return s}function Ee(e,s={}){let t=(i,c)=>s.color?`${c}${i}${be}`:i,n=[];for(let i of b){let c=e.findings.filter((r)=>r.severity===i);if(!c.length)continue;n.push("",t(`${i.toUpperCase()} (${c.length})`,re[i]));for(let r of c){let l=r.file?`${r.file}${r.line?`:${r.line}`:""}`:r.package??"";if(n.push(` ${t(r.rule,Se)} ${r.title}`),l)n.push(` ${l}`);if(r.detail)n.push(` ${t(r.detail,k)}`);if(r.reference)n.push(` ${t(r.reference,k)}`);if(r.fix)n.push(` ${t(`Fix: ${r.fix}`,k)}`)}}if(s.showIgnored&&e.ignored.length){n.push("",t(`SET ASIDE (${e.ignored.length})`,k));for(let i of e.ignored)n.push(` ${i.rule} ${i.file?`${i.file}${i.line?`:${i.line}`:""}`:i.package??""} ${t(`(${i.reason})`,k)}`)}for(let i of e.ignoreProblems)n.push("",t(`! ${i}`,re.high));for(let i of e.skipped)n.push("",t(`- ${i.check} not checked: ${i.reason}`,k));let o=oe(e.findings),a=e.findings.length;if(n.push(""),!a&&!e.ignoreProblems.length)n.push(t("No security findings.",$e)+t(` ${e.checks.join(", ")}; ${e.filesScanned} files${e.ignored.length?`; ${e.ignored.length} set aside with a reason`:""}; ${(e.durationMs/1000).toFixed(1)}s`,k));else n.push(`${a} finding${a===1?"":"s"}: ${b.map((i)=>`${o[i]} ${i}`).join(", ")}`+t(`; ${e.filesScanned} files${e.ignored.length?`; ${e.ignored.length} set aside`:""}; ${(e.durationMs/1000).toFixed(1)}s`,k));return n.join(`
5
- `).replace(/^\n/,"")}var Ae=/(^|\/)\.env(?:\.[\w.-]+)?$/,Ie=/\.(?:example|sample|template|dist|defaults?)$|^\.env\.(?:example|sample|template)/,Re=/\.(?:pem|p12|pfx|jks|keystore|p8|mobileprovision|key)$|(^|\/)(?:id_rsa|id_dsa|id_ecdsa|id_ed25519)$|(^|\/)(?:serviceaccount|service-account|credentials)[\w.-]*\.json$/i,Ce=/(\.pub|\.crt|\.cer)$|(^|\/)(?:fixtures?|tests?|examples?|__tests__)\//i,Te=/secret|passw(?:or)?d|passwd|token|api[_-]?key|private[_-]?key|access[_-]?key|auth[_-]?key|credentials?|dsn|signing|salt|(?:^|_)key$/i,Pe=/^(?:|true|false|null|none|changeme|change-me|example|your[\w-]*|x+|\*+|<[^>]*>|\$\{[^}]*\})$|example|placeholder|dummy|your[_-]/i;function ae(e){let s=[];for(let t of e.split(`
6
- `)){let n=/^\s*(?:export\s+)?([A-Za-z_][\w.]*)\s*=\s*(.*?)\s*$/.exec(t);if(!n||t.trimStart().startsWith("#"))continue;let[,o,a]=n,i=(a??"").replace(/^(['"])(.*)\1$/,"$2");if(/^DOTENV_PUBLIC_KEY/.test(o)||/^(?:encrypted:|enc:|x25519-public:)/i.test(i))continue;if(Te.test(o)&&i.length>=8&&!Pe.test(i))s.push(o)}return s}function B(e,s,t,n={}){let o=[];for(let a of e){let i=a.split("/").pop()??a;if(Ae.test(a)&&!Ie.test(i)){let c=a in n?ae(n[a]):null;if(c&&!c.length)continue;o.push({rule:"hygiene/tracked-env",check:"hygiene",severity:"high",title:"An environment file with readable secrets is committed",file:a,detail:c?`Readable values: ${c.slice(0,6).join(", ")}${c.length>6?` and ${c.length-6} more`:""}`:"Real values in .env files end up in git history for everyone with access.",fix:`Encrypt the values (buddy env:encrypt), or git rm --cached ${a}, ignore it, and rotate every value it held.`})}else if(Re.test(a)&&!Ce.test(a))o.push({rule:"hygiene/tracked-key-material",check:"hygiene",severity:"high",title:"Key or credential material is committed",file:a,fix:"Remove it from git and its history, rotate the key, and load it from a secret store or the environment."})}if(s!==null&&!s.split(`
7
- `).some((a)=>/^\/?\.env(?:\*|\.\*)?\/?$/.test(a.trim())))o.push({rule:"hygiene/gitignore-env",check:"hygiene",severity:"medium",title:".gitignore does not ignore .env",file:".gitignore",fix:"Add a `.env` line (and `.env.*` with a `!.env.example` exception)."});if(t&&!e.some((a)=>/^(?:bun\.lockb?|package-lock\.json|yarn\.lock|pnpm-lock\.yaml)$/.test(a)))o.push({rule:"hygiene/no-lockfile",check:"hygiene",severity:"low",title:"No lockfile is committed, so installs are not reproducible",fix:"Commit bun.lock."});return o}import{readFile as Fe}from"fs/promises";import{join as Le}from"path";var I=".security-ignore.json",Oe=/^\d{4}-\d{2}-\d{2}$/;async function M(e,s){let t;try{t=await Fe(Le(e,I),"utf8")}catch{return{entries:[],problems:[]}}let n=[],o;try{o=JSON.parse(t)}catch{return{entries:[],problems:[`${I} is not valid JSON, so none of its exceptions apply`]}}let a=Array.isArray(o?.ignore)?o.ignore:[],i=[];return a.forEach((c,r)=>{let l=`${I} entry ${r+1}${c?.rule?` (${c.rule})`:""}`;if(!c||typeof c.rule!=="string"||!c.rule)return void n.push(`${l} has no rule`);if(typeof c.reason!=="string"||c.reason.trim().length<8)return void n.push(`${l} needs a reason of at least a few words`);if(c.expires!==void 0&&(typeof c.expires!=="string"||!Oe.test(c.expires)))return void n.push(`${l} has an expires that is not YYYY-MM-DD`);if(c.expires&&c.expires<s)return void n.push(`${l} expired on ${c.expires}; review it and renew or remove it`);i.push({rule:c.rule,path:c.path,reference:c.reference,reason:c.reason.trim(),expires:c.expires})}),{entries:i,problems:n}}function je(e,s){return e===s||e==="*"||e.endsWith("/*")&&s.startsWith(e.slice(0,-1))}function ze(e,s){if(!e)return!0;if(!s)return!1;return e.endsWith("/")?s.startsWith(e):s===e}function H(e,s){return s.find((t)=>je(t.rule,e.rule)&&ze(t.path,e.file)&&(!t.reference||t.reference===e.reference||t.reference===e.package))}import{existsSync as Ze}from"fs";import{readFile as le}from"fs/promises";import{join as R}from"path";import Ke from"process";var Ne=[{rule:"secret/private-key",title:"Private key",pattern:/-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP |ENCRYPTED )?PRIVATE KEY(?: BLOCK)?-----/,severity:"critical"},{rule:"secret/aws-access-key",title:"AWS access key id",pattern:/\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/,severity:"critical"},{rule:"secret/stripe-live-key",title:"Stripe live secret key",pattern:/\b[rs]k_live_[0-9A-Za-z]{20,}\b/,severity:"critical"},{rule:"secret/github-token",title:"GitHub token",pattern:/\b(?:gh[pousr]_[A-Za-z0-9]{36,}|github_pat_[A-Za-z0-9_]{50,})\b/,severity:"critical"},{rule:"secret/anthropic-key",title:"Anthropic API key",pattern:/\bsk-ant-[A-Za-z0-9_-]{32,}\b/,severity:"critical"},{rule:"secret/openai-key",title:"OpenAI API key",pattern:/\bsk-(?:proj-|svcacct-)?[A-Za-z0-9_-]{32,}\b/,severity:"critical",minEntropy:3.6},{rule:"secret/slack-token",title:"Slack token",pattern:/\bxox[abprs]-[0-9A-Za-z-]{10,}\b/,severity:"critical"},{rule:"secret/slack-webhook",title:"Slack webhook URL",pattern:/https:\/\/hooks\.slack\.com\/services\/T[A-Z0-9]{8,}\/B[A-Z0-9]{8,}\/[A-Za-z0-9]{20,}/,severity:"high"},{rule:"secret/google-api-key",title:"Google API key",pattern:/\bAIza[0-9A-Za-z_-]{35}\b/,severity:"high"},{rule:"secret/sendgrid-key",title:"SendGrid API key",pattern:/\bSG\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43}\b/,severity:"critical"},{rule:"secret/twilio-key",title:"Twilio API key",pattern:/\bSK[0-9a-f]{32}\b/,severity:"high",minEntropy:3},{rule:"secret/npm-token",title:"npm access token",pattern:/\bnpm_[A-Za-z0-9]{36}\b/,severity:"critical"},{rule:"secret/database-url",title:"Database URL with a password",pattern:/\b(?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?|redis|amqp):\/\/[^\s:@/'"`]+:(?!\$|\{|%7B|<|\*|\.\.\.|password|pass|secret|changeme|example|user|xxx)[^\s@/'"`]{6,}@(?!localhost\b|127\.0\.0\.1\b|\[::1\]|host\b|example\.|db\b|postgres\b)[\w.-]+/i,severity:"high"}],De=/(^|\/)(?:google-services\.json|GoogleService-Info\.plist)$/,Be=/\b([A-Za-z0-9_]*(?:secret|passw(?:or)?d|passwd|token|api[_-]?key|private[_-]?key|access[_-]?key|auth[_-]?key|credentials?)[A-Za-z0-9_]*)\s*[:=]\s*(['"`])([^'"`\s]{20,})\2/i,Me=/example|placeholder|xxxx|your[_-]|dummy|redacted|fake|\*{4}|0{8}|<[^>]+>/i,U=/example|placeholder|changeme|change-me|your[_-]|<[^>]+>|xxxx|\*{4,}|dummy|sample|redacted|fake|todo|replace|insert|test|demo|foo|bar|0{6,}|1234|abcd|\$\{|\{\{|process\.env|env\(|import\.meta|config\(|%s|%d|undefined|null/i;function q(e){if(!e)return 0;let s=new Map;for(let n of e)s.set(n,(s.get(n)??0)+1);let t=0;for(let n of s.values()){let o=n/e.length;t-=o*Math.log2(o)}return t}function Z(e){if(e.length<=10)return`${e.slice(0,2)}\u2026`;return`${e.slice(0,4)}\u2026${e.slice(-2)} (${e.length} characters)`}function He(e,s,t){let n=/[A-Za-z0-9+/=]{40,}/,o=e[s].slice(t.index+t[0].length).replace(/\\n/g,`
8
- `);if(n.test(o.split(`
9
- `).find((i)=>n.test(i))??""))return!0;let a=e[s+1]?.trim()??"";return n.test(a)||/^(?:Proc-Type|DEK-Info):/.test(a)}function Ue(e){return/^\s*(?:\/\/|#|\*|\/\*|<!--)/.test(e)}function K(e,s){let t=[],n=x(e)||F(e),o=s.split(`
10
- `);for(let a=0;a<o.length;a++){let i=o[a];if(i.length>2000)continue;let c=!1;for(let d of Ne){if(d.rule==="secret/google-api-key"&&De.test(e))continue;let g=d.pattern.exec(i);if(!g)continue;let p=g[0];if(d.rule==="secret/private-key"&&!He(o,a,g))continue;if(d.minEntropy&&q(p)<d.minEntropy)continue;if(d.rule!=="secret/private-key"&&(d.rule==="secret/database-url"?U:Me).test(p))continue;c=!0,t.push({rule:d.rule,check:"secrets",severity:n?"low":d.severity,title:d.title,file:e,line:a+1,detail:d.rule==="secret/private-key"?"A private key block":Z(p),fix:"Revoke and rotate the credential, remove it from the file and from git history, and read it from the environment (.env, which git ignores)."});break}if(c||n||Ue(i))continue;let r=Be.exec(i);if(!r)continue;let l=r[3];if(/^(?:encrypted:|enc:|x25519-public:|\$argon|\$2[aby]\$|sha\d+-)/i.test(l)||/(^|\/)\.env(\.|$)/.test(e))continue;if(/^[\w.@-]+(?:\/[\w.@-]+)+\.\w{1,6}$/.test(l)||/(?:^|_)(?:path|view|screen|route|file|dir|url|uri|href|name|label|selector|header)$/i.test(r[1].replace(/([a-z])([A-Z])/g,"$1_$2")))continue;if(U.test(l)||U.test(r[1])||/^[a-z]+(?:[-_.][a-z]+)+$/i.test(l)||/^https?:\/\//.test(l))continue;if(q(l)<3.7)continue;t.push({rule:"secret/hardcoded-credential",check:"secrets",severity:"medium",title:`Hard-coded value for "${r[1]}"`,file:e,line:a+1,detail:Z(l),fix:"Read it from the environment instead. If it is not a secret, say so with `// security-ignore: secret/hardcoded-credential <why>`."})}return t}var ce=/\$\{\{\s*(?:github\.event\.(?:issue\.(?:title|body)|pull_request\.(?:title|body|head\.(?:ref|label|repo\.default_branch))|comment\.body|review\.body|review_comment\.body|pages\.[\w.*]+\.page_name|commits\.[\w.*]+\.(?:message|author\.(?:email|name))|head_commit\.(?:message|author\.(?:email|name))|discussion\.(?:title|body))|github\.head_ref|github\.event\.workflow_run\.(?:head_branch|head_commit\.message))\s*\}\}/;function qe(e){return e.length-e.trimStart().length}function W(e,s){let t=[],n=s.split(`
11
- `),o=(r,l,d,g,p)=>t.push({rule:r,check:"workflows",severity:l,title:d,file:e,line:g+1,detail:n[g].trim().slice(0,160),fix:p}),a=n.some((r)=>/^\s*(?:on:\s*.*\bpull_request_target\b|pull_request_target\s*:)/.test(r));if(!n.some((r)=>/^permissions\s*:/.test(r))&&n.some((r)=>/^jobs\s*:/.test(r)))t.push({rule:"workflow/default-permissions",check:"workflows",severity:"low",title:"No top-level `permissions:`, so jobs get the repository's default token scope",file:e,line:1,fix:"Add `permissions: { contents: read }` and widen it per job."});let c=-1;for(let r=0;r<n.length;r++){let l=n[r];if(!l.trim()||l.trimStart().startsWith("#"))continue;let d=qe(l);if(c>=0&&d<=c)c=-1;let g=/^(\s*)(?:-\s+)?run\s*:\s*(.*)$/.exec(l);if(g){c=(g[1]??"").length+(l.includes("- run")?2:0);let S=g[2]??"";if(ce.test(S))o("workflow/script-injection","high","Untrusted text is pasted into a shell command",r,'Pass it as an environment variable (env: TITLE: ${{ \u2026 }}) and use "$TITLE" in the script.');if(/echo\s+["']?\$\{\{\s*secrets\.[^|>]*$/.test(S))o("workflow/secret-echo","high","A secret is echoed into the log",r,"Never print secrets; pass them as environment variables to the command that needs them.");continue}if(c>=0&&d>c){if(ce.test(l))o("workflow/script-injection","high","Untrusted text is pasted into a shell command",r,'Pass it as an environment variable (env: TITLE: ${{ \u2026 }}) and use "$TITLE" in the script.');if(/echo\s+["']?\$\{\{\s*secrets\.[^|>]*$/.test(l))o("workflow/secret-echo","high","A secret is echoed into the log",r,"Never print secrets; pass them as environment variables to the command that needs them.")}if(a&&/ref\s*:\s*\$\{\{\s*github\.event\.pull_request\.head\.(?:sha|ref)/.test(l))o("workflow/pull-request-target-checkout","critical","pull_request_target checks out the pull request's code, with the repository's secrets",r,"Use `pull_request`, or run the untrusted code in a separate workflow without secrets.");let p=/^\s*-?\s*uses\s*:\s*([^\s@#]+)@([^\s#]+)/.exec(l);if(p&&!p[1].startsWith("./")&&/^(?:main|master|HEAD|latest)$/.test(p[2]))o("workflow/moving-action-ref","medium",`Action ${p[1]} follows a branch, so its code can change under you`,r,"Pin a release tag or a commit SHA.")}return t}var We=/^\.github\/workflows\/[^/]+\.ya?ml$|(^|\/)action\.ya?ml$/;function Ge(){return new Date().toISOString().slice(0,10)}async function Ve(e){try{let s=Bun.spawn(["git","ls-files","-z","--cached"],{cwd:e,stdout:"pipe",stderr:"ignore"}),[t,n]=await Promise.all([new Response(s.stdout).text(),s.exited]);return n===0?t.split("\x00").filter(Boolean):null}catch{return null}}async function Ye(e,s,t,n=48){let o=0,a=0;async function i(){while(a<s.length){let c=s[a++];if(!P(c))continue;try{let r=Bun.file(R(e,c));if(r.size>Q||r.size===0)continue;let l=await r.text();if(l.slice(0,4096).includes("\x00"))continue;o++,t(c,l)}catch{}}}return await Promise.all(Array.from({length:Math.min(n,Math.max(s.length,1))},i)),o}async function Je(e={}){let s=Date.now(),t=e.cwd??Ke.cwd(),n=e.today??Ge(),o=e.only?.length?e.only:A,a=A.filter((u)=>o.includes(u)&&!e.skip?.includes(u)),i=[],c=[],r=[],l=[],{entries:d,problems:g}=await M(t,n);l.push(...g);let p=(u,f)=>{let h=f&&u.line?O(f,u.line-1):null;if(h&&(!h.rule||h.rule===u.rule||h.rule.endsWith("/*")&&u.rule.startsWith(h.rule.slice(0,-1)))){if(h.reason.length>=8)return void r.push({...u,reason:h.reason});l.push(`${u.file}:${u.line} has a security-ignore comment without a reason, so ${u.rule} still counts`)}let m=H(u,d);if(m)return void r.push({...u,reason:m.reason});c.push(u)},S=a.some((u)=>u==="secrets"||u==="code"||u==="workflows"),G=0,_=[];if(S)_.push((async()=>{let{files:u}=await L(t),f=a.includes("secrets"),h=a.includes("code"),m=a.includes("workflows");G=await Ye(t,u,(v,E)=>{let C=E.split(`
12
- `);if(f)for(let w of K(v,E))p(w,C);if(h)for(let w of j(v,E))p(w,C);if(m&&We.test(v))for(let w of W(v,E))p(w,C)})})());if(a.includes("hygiene"))_.push((async()=>{let u=await Ve(t);if(!u)return void i.push({check:"hygiene",reason:"not a git checkout"});let f=await le(R(t,".gitignore"),"utf8").catch(()=>null),h={};await Promise.all(u.filter((m)=>/(^|\/)\.env(\.[\w.-]+)?$/.test(m)).map(async(m)=>{let v=await le(R(t,m),"utf8").catch(()=>null);if(v!==null)h[m]=v}));for(let m of B(u,f,Ze(R(t,"package.json")),h))p(m)})());if(a.includes("dependencies"))if(e.offline)i.push({check:"dependencies",reason:"offline: the advisory database needs the network"});else _.push((async()=>{let u=await z(t);if("skipped"in u)i.push(u.skipped);else for(let f of u.findings)p(f)})());await Promise.all(_);let V=["critical","high","medium","low"],Y=(u,f)=>V.indexOf(u.severity)-V.indexOf(f.severity)||(u.file??u.package??"").localeCompare(f.file??f.package??"")||(u.line??0)-(f.line??0)||u.rule.localeCompare(f.rule);return{cwd:t,checks:a,skipped:i.sort((u,f)=>u.check.localeCompare(f.check)),filesScanned:G,findings:c.sort(Y),ignored:r.sort(Y),ignoreProblems:l,durationMs:Date.now()-s}}export{I as IGNORE_FILE,A as SCAN_CHECKS,b as SEVERITIES,se as advisoryId,D as atLeast,z as auditDependencies,q as entropy,_e as failsScan,Ee as formatScan,O as inlineIgnore,F as isDocPath,P as isScannable,x as isTestPath,M as loadIgnores,H as matchingIgnore,ie as parseBunAudit,ae as plaintextSecrets,L as projectFiles,Z as redact,Je as runSecurityScan,j as scanCode,B as scanHygiene,K as scanSecrets,W as scanWorkflow,oe as severityCounts,N as severityRank};
2
+ import{readdir as ue,stat as de}from"fs/promises";import{join as C,relative as pe}from"path";var Y=new Set([".git","node_modules","dist","build",".next",".turbo","coverage",".zig-cache","zig-cache","pantry",".cache"]),fe=new Set(["png","jpg","jpeg","gif","webp","avif","ico","icns","bmp","tiff","heic","mp3","mp4","mov","webm","m4a","wav","ogg","flac","aac","woff","woff2","ttf","otf","eot","pdf","zip","gz","tgz","br","zst","xz","7z","tar","wasm","so","dylib","a","o","node","db","sqlite","sqlite3","mdb","psd","ai","sketch","fig","car","nib","riv","lottie","glb","gltf","usdz","fit","bin","dat","map"]),ge=/(^|\/)(bun\.lockb?|package-lock\.json|yarn\.lock|pnpm-lock\.yaml|pantry\.lock|Cargo\.lock|Package\.resolved|Podfile\.lock)$|\.min\.(js|css)$|\.snap$/,J=1e6;function T(e){if(ge.test(e))return!1;let i=e.split(".").pop()?.toLowerCase()??"";return!fe.has(i)}function b(e){return/(^|\/)(tests?|__tests__|__mocks__|fixtures?|examples?|e2e|mocks?|stubs?|seeders?|bench|benchmarks?)\//.test(e)||/\.(test|spec)\.[cm]?[jt]sx?$/.test(e)}function F(e){return/\.(md|mdx|txt|rst)$/i.test(e)||/(^|\/)docs?\//.test(e)}async function Q(e,i,t){let n=await ue(i,{withFileTypes:!0}).catch(()=>[]);for(let a of n)if(a.isDirectory()){if(!Y.has(a.name))await Q(e,C(i,a.name),t)}else if(a.isFile())t.push(pe(e,C(i,a.name)))}async function P(e){try{let t=Bun.spawn(["git","ls-files","-z","--cached","--others","--exclude-standard"],{cwd:e,stdout:"pipe",stderr:"ignore"}),[n,a]=await Promise.all([new Response(t.stdout).text(),t.exited]);if(a===0){let c=[];for(let s of n.split("\x00")){if(!s||s.split("/").some((o)=>Y.has(o)))continue;if((await de(C(e,s)).catch(()=>null))?.isFile())c.push(s)}return{files:[...new Set(c)].sort(),tracked:!0}}}catch{}let i=[];return await Q(e,e,i),{files:i.sort(),tracked:!1}}var he=/\bparam\w*\(|\bplaceholders?\b|\bquote\w*\(|\bescape\w*\(|\bident\w*\(|^(?:q|qi|\d+)$|^[a-z]{1,2}\(\d+\)$|^\w*(?:Placeholders?|Quoted|Columns?|Table(?:Name)?|Qualified|Sql|Clause|Cascade)\w*$/i;function me(e){let i=[...e.matchAll(/\$\{([^}]*)\}/g)].map((t)=>(t[1]??"").trim());return i.length>0&&i.every((t)=>he.test(t))}function ye(e){let i=e.split(/\.(?:innerHTML|outerHTML)\s*\+?=(?!=)/)[1];if(i===void 0)return!0;return!/^\s*(['"`])(?:(?!\$\{)[^'"`\\])*\1\s*;?\s*(?:\/\/.*)?$/.test(i)}var y=/\.(?:[cm]?[jt]sx?|stx|vue|svelte|html)$/i,ke=[{rule:"code/eval",title:"eval() or new Function() runs a string as code",pattern:/(?<![.\w$])(?:eval\s*\(|new\s+Function\s*\()/,severity:"high",fix:"Parse data with JSON.parse or a real parser, and pick behaviour from a lookup table instead of building code.",skipTests:!0,ext:y},{rule:"code/command-injection",title:"Shell command built from a string with interpolation",pattern:/(?<![.\w$])(?:exec|execSync)\s*\(\s*(?:`[^`]*\$\{|(?:'[^']*'|"[^"]*")\s*\+)|\b(?:child_process|cp|childProcess)\.(?:exec|execSync)\s*\(\s*(?:`[^`]*\$\{|(?:'[^']*'|"[^"]*")\s*\+)|\bspawn(?:Sync)?\s*\(\s*['"`](?:ba|z|da)?sh['"`]\s*,\s*\[\s*['"`]-c['"`]\s*,\s*(?:`[^`]*\$\{|[A-Za-z_$])/,severity:"high",fix:"Pass the program and its arguments separately (Bun.spawn([cmd, ...args]) or Bun's $ template), never one string.",skipTests:!0,ext:y},{rule:"code/sql-interpolation",title:"SQL built with string interpolation",pattern:/(?:\.unsafe|\bsql\.raw|\.\$queryRawUnsafe|\.executeRaw)\s*\(\s*(?:`[^`]*\$\{|(?:'[^']*'|"[^"]*")\s*\+)/,severity:"medium",fix:"Use parameter placeholders ($1, ?) and pass the values separately; interpolate only fixed identifiers (a table or column from a list in your code).",skipTests:!0,ext:y,accept:(e)=>!me(e)},{rule:"code/insecure-tls",title:"TLS certificate verification is turned off",pattern:/rejectUnauthorized\s*:\s*false|NODE_TLS_REJECT_UNAUTHORIZED\s*[=:]\s*['"`]?0|tls\s*:\s*\{[^}]*rejectUnauthorized\s*:\s*false/,severity:"high",fix:"Trust the real certificate (or add the CA) instead of disabling verification.",skipTests:!0,ext:y},{rule:"code/jwt-none",title:'A token algorithm of "none" skips signature checks',pattern:/\balg(?:orithms?)?\s*:\s*\[?\s*['"`]none['"`]/i,severity:"high",fix:"Pin the expected algorithm when verifying a token.",skipTests:!0,ext:y},{rule:"code/inner-html",title:"Markup written straight into the page",pattern:/\.(?:innerHTML|outerHTML)\s*[+]?=(?!=)|\binsertAdjacentHTML\s*\(|\bdocument\.write(?:ln)?\s*\(/,severity:"medium",fix:"Set textContent, or render through the template engine, which escapes by default. If the HTML is sanitized, say so with a security-ignore comment.",skipTests:!0,ext:y,accept:(e)=>/insertAdjacentHTML|document\.write/.test(e)||ye(e)},{rule:"code/random-token",title:"Math.random() used for something secret",pattern:/Math\.random\(\)[^\n]*\b(?:token|secret|password|nonce|otp|csrf|api.?key)\b|\b(?:token|secret|password|nonce|otp|csrf|api.?key)\b[^\n]*Math\.random\(\)/i,severity:"medium",fix:"Use crypto.getRandomValues() or crypto.randomUUID().",skipTests:!0,ext:y,accept:(e)=>!/setTimeout|setInterval|delay|jitter|backoff|retry|sleep/i.test(e)},{rule:"code/weak-hash",title:"MD5 or SHA-1 used as a hash",pattern:/createHash\(\s*['"`](?:md5|sha1)['"`]\s*\)/i,severity:"low",fix:"Use SHA-256 or better; for passwords use the password hasher (make/check). Fine for a non-security cache key or a vendor's required format: say so with a security-ignore comment.",skipTests:!0,ext:y},{rule:"code/cors-wildcard-credentials",title:"CORS allows every origin with credentials",pattern:/Access-Control-Allow-Origin['"`]?\s*[:,]\s*['"`]\*['"`][^\n]*(?:credentials|Allow-Credentials)|(?:credentials|Allow-Credentials)[^\n]*Access-Control-Allow-Origin['"`]?\s*[:,]\s*['"`]\*['"`]/i,severity:"medium",fix:"Name the allowed origins; browsers refuse a wildcard with credentials anyway.",skipTests:!0,ext:y},{rule:"code/disabled-csrf",title:"CSRF protection turned off",pattern:/\bcsrf\s*:\s*false\b|verifyCsrf\s*=\s*false/i,severity:"medium",fix:"Leave CSRF protection on for anything a browser session can reach.",skipTests:!0,ext:y}],ve=/security-ignore(?::|\s|$)\s*([a-z][\w/-]*)?\s*(.*)$/i;function L(e,i){for(let t of[e[i],e[i-1]]){if(t===void 0)continue;let n=ve.exec(t);if(n){let a=n[1]?.includes("/")?n[1]:null,c=(a?n[2]??"":`${n[1]??""} ${n[2]??""}`).replace(/\s*(?:-->|\*\/)\s*$/,"").trim();return{rule:a,reason:c}}}return null}function we(e){return/^\s*(?:\/\/|\*|\/\*|#|<!--)/.test(e)}function O(e,i){let t=[],n=b(e),a=ke.filter((s)=>(!s.skipTests||!n)&&(!s.ext||s.ext.test(e)));if(!a.length)return t;let c=i.split(`
3
+ `);for(let s=0;s<c.length;s++){let r=c[s];if(r.length>2000||we(r))continue;for(let o of a){if(!o.pattern.test(r)||o.accept&&!o.accept(r))continue;t.push({rule:o.rule,check:"code",severity:o.severity,title:o.title,file:e,line:s+1,detail:r.trim().slice(0,160),fix:o.fix})}}return t}import{existsSync as X}from"fs";import{join as ee}from"path";import te from"process";var be={critical:"critical",high:"high",moderate:"medium",medium:"medium",low:"low"};function ne(e){return e?.match(/GHSA-[\w-]+/i)?.[0]??e}function se(e){let i;try{i=JSON.parse(e)}catch{return null}if(!i||typeof i!=="object"||Array.isArray(i))return null;let t=[];for(let[a,c]of Object.entries(i)){if(!Array.isArray(c))continue;for(let s of c){let r=be[String(s.severity??"").toLowerCase()]??"medium",o=s.vulnerable_versions?` (${s.vulnerable_versions})`:"";t.push({rule:"dependency/advisory",check:"dependencies",severity:r,title:s.title||`Known vulnerability in ${a}`,package:a,detail:`${a}${o}${s.cvss?.score?`, CVSS ${s.cvss.score}`:""}`,reference:ne(s.url),fix:`Raise ${a} outside ${s.vulnerable_versions||"the affected range"}: update the package that depends on it (bun update ${a} --recursive) so the lockfile resolves a fixed version. Do not pin it with overrides.`})}}let n=(a)=>["critical","high","medium","low"].indexOf(a);return t.sort((a,c)=>n(a.severity)-n(c.severity)||(a.package??"").localeCompare(c.package??""))}async function j(e,i=60000){let t=(n)=>({skipped:{check:"dependencies",reason:n}});if(!X(ee(e,"bun.lock"))&&!X(ee(e,"bun.lockb")))return t("no bun.lock to audit");try{let n=Bun.spawn([te.execPath,"audit","--json"],{cwd:e,stdout:"pipe",stderr:"pipe",env:{...te.env,NO_COLOR:"1"}}),a=setTimeout(()=>n.kill(),i),[c,s,r]=await Promise.all([new Response(n.stdout).text(),new Response(n.stderr).text(),n.exited]);clearTimeout(a);let o=se(c.trim()||"{}");if(o)return{findings:o};let l=(s||c).trim().split(`
4
+ `).find((d)=>d.trim())??`exited ${r}`;return t(`bun audit gave no report (${l.slice(0,160)})`)}catch(n){return t(`bun audit could not run (${n instanceof Error?n.message:String(n)})`)}}var x=["critical","high","medium","low"],E=["dependencies","secrets","code","workflows","hygiene"];function z(e){return x.indexOf(e)}function N(e,i){return z(e)<=z(i)}var ie={critical:"\x1B[1;31m",high:"\x1B[31m",medium:"\x1B[33m",low:"\x1B[2m"},xe="\x1B[0m",k="\x1B[2m",Se="\x1B[1m",$e="\x1B[32m";function _e(e,i){if(i==="none")return!1;return e.findings.some((t)=>N(t.severity,i))||e.ignoreProblems.length>0}function re(e){let i={critical:0,high:0,medium:0,low:0};for(let t of e)i[t.severity]++;return i}function Ee(e,i={}){let t=(s,r)=>i.color?`${r}${s}${xe}`:s,n=[];for(let s of x){let r=e.findings.filter((o)=>o.severity===s);if(!r.length)continue;n.push("",t(`${s.toUpperCase()} (${r.length})`,ie[s]));for(let o of r){let l=o.file?`${o.file}${o.line?`:${o.line}`:""}`:o.package??"";if(n.push(` ${t(o.rule,Se)} ${o.title}`),l)n.push(` ${l}`);if(o.detail)n.push(` ${t(o.detail,k)}`);if(o.reference)n.push(` ${t(o.reference,k)}`);if(o.fix)n.push(` ${t(`Fix: ${o.fix}`,k)}`)}}if(i.showIgnored&&e.ignored.length){n.push("",t(`SET ASIDE (${e.ignored.length})`,k));for(let s of e.ignored)n.push(` ${s.rule} ${s.file?`${s.file}${s.line?`:${s.line}`:""}`:s.package??""} ${t(`(${s.reason})`,k)}`)}for(let s of e.ignoreProblems)n.push("",t(`! ${s}`,ie.high));for(let s of e.skipped)n.push("",t(`- ${s.check} not checked: ${s.reason}`,k));let a=re(e.findings),c=e.findings.length;if(n.push(""),!c&&!e.ignoreProblems.length)n.push(t("No security findings.",$e)+t(` ${e.checks.join(", ")}; ${e.filesScanned} files${e.ignored.length?`; ${e.ignored.length} set aside with a reason`:""}; ${(e.durationMs/1000).toFixed(1)}s`,k));else n.push(`${c} finding${c===1?"":"s"}: ${x.map((s)=>`${a[s]} ${s}`).join(", ")}`+t(`; ${e.filesScanned} files${e.ignored.length?`; ${e.ignored.length} set aside`:""}; ${(e.durationMs/1000).toFixed(1)}s`,k));return n.join(`
5
+ `).replace(/^\n/,"")}var Ae=/(^|\/)\.env(?:\.[\w.-]+)?$/,Ie=/\.(?:example|sample|template|dist|defaults?)$|^\.env\.(?:example|sample|template)/,Re=/\.(?:pem|p12|pfx|jks|keystore|p8|mobileprovision|key)$|(^|\/)(?:id_rsa|id_dsa|id_ecdsa|id_ed25519)$|(^|\/)(?:serviceaccount|service-account|credentials)[\w.-]*\.json$/i,Ce=/(\.pub|\.crt|\.cer)$|(^|\/)(?:fixtures?|tests?|examples?|__tests__)\//i,Te=/secret|passw(?:or)?d|passwd|token|api[_-]?key|private[_-]?key|access[_-]?key|auth[_-]?key|credentials?|dsn|signing|salt|(?:^|_)key$/i,Fe=/^(?:|true|false|null|none|changeme|change-me|example|your[\w-]*|x+|\*+|<[^>]*>|\$\{[^}]*\})$|example|placeholder|dummy|your[_-]/i;function oe(e){let i=[];for(let t of e.split(`
6
+ `)){let n=/^\s*(?:export\s+)?([A-Za-z_][\w.]*)\s*=\s*(.*?)\s*$/.exec(t);if(!n||t.trimStart().startsWith("#"))continue;let[,a,c]=n,s=(c??"").replace(/^(['"])(.*)\1$/,"$2");if(/^DOTENV_PUBLIC_KEY/.test(a)||/^(?:encrypted:|enc:|x25519-public:)/i.test(s))continue;if(Te.test(a)&&s.length>=8&&!Fe.test(s))i.push(a)}return i}function D(e,i,t,n={}){let a=[];for(let c of e){let s=c.split("/").pop()??c;if(Ae.test(c)&&!Ie.test(s)){let r=c in n?oe(n[c]):null;if(r&&!r.length)continue;a.push({rule:"hygiene/tracked-env",check:"hygiene",severity:"high",title:"An environment file with readable secrets is committed",file:c,detail:r?`Readable values: ${r.slice(0,6).join(", ")}${r.length>6?` and ${r.length-6} more`:""}`:"Real values in .env files end up in git history for everyone with access.",fix:`Encrypt the values (buddy env:encrypt), or git rm --cached ${c}, ignore it, and rotate every value it held.`})}else if(Re.test(c)&&!Ce.test(c))a.push({rule:"hygiene/tracked-key-material",check:"hygiene",severity:"high",title:"Key or credential material is committed",file:c,fix:"Remove it from git and its history, rotate the key, and load it from a secret store or the environment."})}if(i!==null&&!i.split(`
7
+ `).some((c)=>/^\/?\.env(?:\*|\.\*)?\/?$/.test(c.trim())))a.push({rule:"hygiene/gitignore-env",check:"hygiene",severity:"medium",title:".gitignore does not ignore .env",file:".gitignore",fix:"Add a `.env` line (and `.env.*` with a `!.env.example` exception)."});if(t&&!e.some((c)=>/^(?:bun\.lockb?|package-lock\.json|yarn\.lock|pnpm-lock\.yaml)$/.test(c)))a.push({rule:"hygiene/no-lockfile",check:"hygiene",severity:"low",title:"No lockfile is committed, so installs are not reproducible",fix:"Commit bun.lock."});return a}import{readFile as Pe}from"fs/promises";import{join as Le}from"path";var A=".security-ignore.json",Oe=/^\d{4}-\d{2}-\d{2}$/;async function B(e,i){let t;try{t=await Pe(Le(e,A),"utf8")}catch{return{entries:[],problems:[]}}let n=[],a;try{a=JSON.parse(t)}catch{return{entries:[],problems:[`${A} is not valid JSON, so none of its exceptions apply`]}}let c=Array.isArray(a?.ignore)?a.ignore:[],s=[];return c.forEach((r,o)=>{let l=`${A} entry ${o+1}${r?.rule?` (${r.rule})`:""}`;if(!r||typeof r.rule!=="string"||!r.rule)return void n.push(`${l} has no rule`);if(typeof r.reason!=="string"||r.reason.trim().length<8)return void n.push(`${l} needs a reason of at least a few words`);if(r.expires!==void 0&&(typeof r.expires!=="string"||!Oe.test(r.expires)))return void n.push(`${l} has an expires that is not YYYY-MM-DD`);if(r.expires&&r.expires<i)return void n.push(`${l} expired on ${r.expires}; review it and renew or remove it`);s.push({rule:r.rule,path:r.path,reference:r.reference,reason:r.reason.trim(),expires:r.expires})}),{entries:s,problems:n}}function je(e,i){return e===i||e==="*"||e.endsWith("/*")&&i.startsWith(e.slice(0,-1))}function ze(e,i){if(!e)return!0;if(!i)return!1;return e.endsWith("/")?i.startsWith(e):i===e}function M(e,i){return i.find((t)=>je(t.rule,e.rule)&&ze(t.path,e.file)&&(!t.reference||t.reference===e.reference||t.reference===e.package))}import{existsSync as Ze}from"fs";import{readFile as ce}from"fs/promises";import{join as I}from"path";import We from"process";var Ne=[{rule:"secret/private-key",title:"Private key",pattern:/-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP |ENCRYPTED )?PRIVATE KEY(?: BLOCK)?-----/,severity:"critical"},{rule:"secret/aws-access-key",title:"AWS access key id",pattern:/\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/,severity:"critical"},{rule:"secret/stripe-live-key",title:"Stripe live secret key",pattern:/\b[rs]k_live_[0-9A-Za-z]{20,}\b/,severity:"critical"},{rule:"secret/github-token",title:"GitHub token",pattern:/\b(?:gh[pousr]_[A-Za-z0-9]{36,}|github_pat_[A-Za-z0-9_]{50,})\b/,severity:"critical"},{rule:"secret/anthropic-key",title:"Anthropic API key",pattern:/\bsk-ant-[A-Za-z0-9_-]{32,}\b/,severity:"critical"},{rule:"secret/openai-key",title:"OpenAI API key",pattern:/\bsk-(?:proj-|svcacct-)?[A-Za-z0-9_-]{32,}\b/,severity:"critical",minEntropy:3.6},{rule:"secret/slack-token",title:"Slack token",pattern:/\bxox[abprs]-[0-9A-Za-z-]{10,}\b/,severity:"critical"},{rule:"secret/slack-webhook",title:"Slack webhook URL",pattern:/https:\/\/hooks\.slack\.com\/services\/T[A-Z0-9]{8,}\/B[A-Z0-9]{8,}\/[A-Za-z0-9]{20,}/,severity:"high"},{rule:"secret/google-api-key",title:"Google API key",pattern:/\bAIza[0-9A-Za-z_-]{35}\b/,severity:"high"},{rule:"secret/sendgrid-key",title:"SendGrid API key",pattern:/\bSG\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43}\b/,severity:"critical"},{rule:"secret/twilio-key",title:"Twilio API key",pattern:/\bSK[0-9a-f]{32}\b/,severity:"high",minEntropy:3},{rule:"secret/npm-token",title:"npm access token",pattern:/\bnpm_[A-Za-z0-9]{36}\b/,severity:"critical"},{rule:"secret/database-url",title:"Database URL with a password",pattern:/\b(?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?|redis|amqp):\/\/[^\s:@/'"`]+:(?!\$|\{|%7B|<|\*|\.\.\.|password|pass|secret|changeme|example|user|xxx)[^\s@/'"`]{6,}@(?!localhost\b|127\.0\.0\.1\b|\[::1\]|host\b|example\.|db\b|postgres\b)[\w.-]+/i,severity:"high"}],De=/(^|\/)(?:google-services\.json|GoogleService-Info\.plist)$/,Be=/\b([A-Za-z0-9_]*(?:secret|passw(?:or)?d|passwd|token|api[_-]?key|private[_-]?key|access[_-]?key|auth[_-]?key|credentials?)[A-Za-z0-9_]*)\s*[:=]\s*(['"`])([^'"`\s]{20,})\2/i,Me=/example|placeholder|xxxx|your[_-]|dummy|redacted|fake|\*{4}|0{8}|<[^>]+>/i,H=/example|placeholder|changeme|change-me|your[_-]|<[^>]+>|xxxx|\*{4,}|dummy|sample|redacted|fake|todo|replace|insert|test|demo|foo|bar|0{6,}|1234|abcd|\$\{|\{\{|process\.env|env\(|import\.meta|config\(|%s|%d|undefined|null/i;function U(e){if(!e)return 0;let i=new Map;for(let n of e)i.set(n,(i.get(n)??0)+1);let t=0;for(let n of i.values()){let a=n/e.length;t-=a*Math.log2(a)}return t}function q(e){if(e.length<=10)return`${e.slice(0,2)}\u2026`;return`${e.slice(0,4)}\u2026${e.slice(-2)} (${e.length} characters)`}function He(e,i,t){let n=/[A-Za-z0-9+/=]{40,}/,a=e[i].slice(t.index+t[0].length).replace(/\\n/g,`
8
+ `);if(n.test(a.split(`
9
+ `).find((s)=>n.test(s))??""))return!0;let c=e[i+1]?.trim()??"";return n.test(c)||/^(?:Proc-Type|DEK-Info):/.test(c)}function Ue(e){return/^\s*(?:\/\/|#|\*|\/\*|<!--)/.test(e)}function Z(e,i){let t=[],n=b(e)||F(e),a=i.split(`
10
+ `);for(let c=0;c<a.length;c++){let s=a[c];if(s.length>2000)continue;let r=!1;for(let d of Ne){if(d.rule==="secret/google-api-key"&&De.test(e))continue;let p=d.pattern.exec(s);if(!p)continue;let g=p[0];if(d.rule==="secret/private-key"&&!He(a,c,p))continue;if(d.minEntropy&&U(g)<d.minEntropy)continue;if(d.rule!=="secret/private-key"&&(d.rule==="secret/database-url"?H:Me).test(g))continue;r=!0,t.push({rule:d.rule,check:"secrets",severity:n?"low":d.severity,title:d.title,file:e,line:c+1,detail:d.rule==="secret/private-key"?"A private key block":q(g),fix:"Revoke and rotate the credential, remove it from the file and from git history, and read it from the environment (.env, which git ignores)."});break}if(r||n||Ue(s))continue;let o=Be.exec(s);if(!o)continue;let l=o[3];if(/^(?:encrypted:|enc:|x25519-public:|\$argon|\$2[aby]\$|sha\d+-)/i.test(l)||/(^|\/)\.env(\.|$)/.test(e))continue;if(/^[\w.@-]+(?:\/[\w.@-]+)+\.\w{1,6}$/.test(l)||/(?:^|_)(?:path|view|screen|route|file|dir|url|uri|href|name|label|selector|header)$/i.test(o[1].replace(/([a-z])([A-Z])/g,"$1_$2")))continue;if(H.test(l)||H.test(o[1])||/^[a-z]+(?:[-_.][a-z]+)+$/i.test(l)||/^https?:\/\//.test(l))continue;if(U(l)<3.7)continue;t.push({rule:"secret/hardcoded-credential",check:"secrets",severity:"medium",title:`Hard-coded value for "${o[1]}"`,file:e,line:c+1,detail:q(l),fix:"Read it from the environment instead. If it is not a secret, say so with `// security-ignore: secret/hardcoded-credential <why>`."})}return t}var ae=/\$\{\{\s*(?:github\.event\.(?:issue\.(?:title|body)|pull_request\.(?:title|body|head\.(?:ref|label|repo\.default_branch))|comment\.body|review\.body|review_comment\.body|pages\.[\w.*]+\.page_name|commits\.[\w.*]+\.(?:message|author\.(?:email|name))|head_commit\.(?:message|author\.(?:email|name))|discussion\.(?:title|body))|github\.head_ref|github\.event\.workflow_run\.(?:head_branch|head_commit\.message))\s*\}\}/;function qe(e){return e.length-e.trimStart().length}function W(e,i){let t=[],n=i.split(`
11
+ `),a=(r,o,l,d,p)=>t.push({rule:r,check:"workflows",severity:o,title:l,file:e,line:d+1,detail:n[d].trim().slice(0,160),fix:p}),c=n.some((r)=>/^\s*(?:on:\s*.*\bpull_request_target\b|pull_request_target\s*:)/.test(r));if(!n.some((r)=>/^permissions\s*:/.test(r))){let r=n.findIndex((o)=>/^jobs\s*:/.test(o));if(r>=0){let o=[],l=null,d=()=>{if(l&&!l.scoped)o.push(l.name)};for(let p of n.slice(r+1)){if(/^\S/.test(p)&&p.trim()&&!p.startsWith("#"))break;let g=/^ {2}([\w-]+)\s*:/.exec(p);if(g)d(),l={name:g[1],scoped:!1};else if(l&&/^ {4}permissions\s*:/.test(p))l.scoped=!0}if(d(),o.length)t.push({rule:"workflow/default-permissions",check:"workflows",severity:"low",title:`No \`permissions:\` for ${o.length===1?`job ${o[0]}`:`${o.length} jobs`}, so they get the repository's default token scope`,file:e,line:r+1,detail:o.join(", "),fix:"Add `permissions: { contents: read }` at the top of the workflow and widen it per job."})}}let s=-1;for(let r=0;r<n.length;r++){let o=n[r];if(!o.trim()||o.trimStart().startsWith("#"))continue;let l=qe(o);if(s>=0&&l<=s)s=-1;let d=/^(\s*)(?:-\s+)?run\s*:\s*(.*)$/.exec(o);if(d){s=(d[1]??"").length+(o.includes("- run")?2:0);let g=d[2]??"";if(ae.test(g))a("workflow/script-injection","high","Untrusted text is pasted into a shell command",r,'Pass it as an environment variable (env: TITLE: ${{ \u2026 }}) and use "$TITLE" in the script.');if(/echo\s+["']?\$\{\{\s*secrets\.[^|>]*$/.test(g))a("workflow/secret-echo","high","A secret is echoed into the log",r,"Never print secrets; pass them as environment variables to the command that needs them.");continue}if(s>=0&&l>s){if(ae.test(o))a("workflow/script-injection","high","Untrusted text is pasted into a shell command",r,'Pass it as an environment variable (env: TITLE: ${{ \u2026 }}) and use "$TITLE" in the script.');if(/echo\s+["']?\$\{\{\s*secrets\.[^|>]*$/.test(o))a("workflow/secret-echo","high","A secret is echoed into the log",r,"Never print secrets; pass them as environment variables to the command that needs them.")}if(c&&/ref\s*:\s*\$\{\{\s*github\.event\.pull_request\.head\.(?:sha|ref)/.test(o))a("workflow/pull-request-target-checkout","critical","pull_request_target checks out the pull request's code, with the repository's secrets",r,"Use `pull_request`, or run the untrusted code in a separate workflow without secrets.");let p=/^\s*-?\s*uses\s*:\s*([^\s@#]+)@([^\s#]+)/.exec(o);if(p&&!p[1].startsWith("./")&&/^(?:main|master|HEAD|latest)$/.test(p[2]))a("workflow/moving-action-ref","medium",`Action ${p[1]} follows a branch, so its code can change under you`,r,"Pin a release tag or a commit SHA.")}return t}var Ke=/^\.github\/workflows\/[^/]+\.ya?ml$|(^|\/)action\.ya?ml$/;function Ge(){return new Date().toISOString().slice(0,10)}async function Ve(e){try{let i=Bun.spawn(["git","ls-files","-z","--cached"],{cwd:e,stdout:"pipe",stderr:"ignore"}),[t,n]=await Promise.all([new Response(i.stdout).text(),i.exited]);return n===0?t.split("\x00").filter(Boolean):null}catch{return null}}async function Ye(e,i,t,n=48){let a=0,c=0;async function s(){while(c<i.length){let r=i[c++];if(!T(r))continue;try{let o=Bun.file(I(e,r));if(o.size>J||o.size===0)continue;let l=await o.text();if(l.slice(0,4096).includes("\x00"))continue;a++,t(r,l)}catch{}}}return await Promise.all(Array.from({length:Math.min(n,Math.max(i.length,1))},s)),a}async function Je(e={}){let i=Date.now(),t=e.cwd??We.cwd(),n=e.today??Ge(),a=e.only?.length?e.only:E,c=E.filter((u)=>a.includes(u)&&!e.skip?.includes(u)),s=[],r=[],o=[],l=[],{entries:d,problems:p}=await B(t,n);l.push(...p);let g=(u,f)=>{let h=f&&u.line?L(f,u.line-1):null;if(h&&(!h.rule||h.rule===u.rule||h.rule.endsWith("/*")&&u.rule.startsWith(h.rule.slice(0,-1)))){if(h.reason.length>=8)return void o.push({...u,reason:h.reason});l.push(`${u.file}:${u.line} has a security-ignore comment without a reason, so ${u.rule} still counts`)}let m=M(u,d);if(m)return void o.push({...u,reason:m.reason});r.push(u)},le=c.some((u)=>u==="secrets"||u==="code"||u==="workflows"),K=0,S=[];if(le)S.push((async()=>{let{files:u}=await P(t),f=c.includes("secrets"),h=c.includes("code"),m=c.includes("workflows");K=await Ye(t,u,(v,_)=>{let R=_.split(`
12
+ `);if(f)for(let w of Z(v,_))g(w,R);if(h)for(let w of O(v,_))g(w,R);if(m&&Ke.test(v))for(let w of W(v,_))g(w,R)})})());if(c.includes("hygiene"))S.push((async()=>{let u=await Ve(t);if(!u)return void s.push({check:"hygiene",reason:"not a git checkout"});let f=await ce(I(t,".gitignore"),"utf8").catch(()=>null),h={};await Promise.all(u.filter((m)=>/(^|\/)\.env(\.[\w.-]+)?$/.test(m)).map(async(m)=>{let v=await ce(I(t,m),"utf8").catch(()=>null);if(v!==null)h[m]=v}));for(let m of D(u,f,Ze(I(t,"package.json")),h))g(m)})());if(c.includes("dependencies"))if(e.offline)s.push({check:"dependencies",reason:"offline: the advisory database needs the network"});else S.push((async()=>{let u=await j(t);if("skipped"in u)s.push(u.skipped);else for(let f of u.findings)g(f)})());await Promise.all(S);let G=["critical","high","medium","low"],V=(u,f)=>G.indexOf(u.severity)-G.indexOf(f.severity)||(u.file??u.package??"").localeCompare(f.file??f.package??"")||(u.line??0)-(f.line??0)||u.rule.localeCompare(f.rule);return{cwd:t,checks:c,skipped:s.sort((u,f)=>u.check.localeCompare(f.check)),filesScanned:K,findings:r.sort(V),ignored:o.sort(V),ignoreProblems:l,durationMs:Date.now()-i}}export{A as IGNORE_FILE,E as SCAN_CHECKS,x as SEVERITIES,ne as advisoryId,N as atLeast,j as auditDependencies,U as entropy,_e as failsScan,Ee as formatScan,L as inlineIgnore,F as isDocPath,T as isScannable,b as isTestPath,B as loadIgnores,M as matchingIgnore,se as parseBunAudit,oe as plaintextSecrets,P as projectFiles,q as redact,Je as runSecurityScan,O as scanCode,D as scanHygiene,Z as scanSecrets,W as scanWorkflow,re as severityCounts,z as severityRank};
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@stacksjs/security",
3
3
  "type": "module",
4
4
  "sideEffects": false,
5
- "version": "0.75.398",
5
+ "version": "0.75.399",
6
6
  "description": "The Stacks framework security.",
7
7
  "author": "Chris Breuer",
8
8
  "contributors": [
@@ -69,13 +69,13 @@
69
69
  "prepublishOnly": "bun run build"
70
70
  },
71
71
  "dependencies": {
72
- "@stacksjs/config": "0.75.398",
72
+ "@stacksjs/config": "0.75.399",
73
73
  "ts-security-crypto": "0.0.2"
74
74
  },
75
75
  "devDependencies": {
76
- "@stacksjs/env": "0.75.398",
77
- "@stacksjs/types": "0.75.398",
78
- "@stacksjs/validation": "0.75.398",
76
+ "@stacksjs/env": "0.75.399",
77
+ "@stacksjs/types": "0.75.399",
78
+ "@stacksjs/validation": "0.75.399",
79
79
  "better-dx": "^0.2.24"
80
80
  }
81
81
  }