@stacksjs/security 0.75.390 → 0.75.397
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.
- package/dist/audit/code.d.ts +6 -0
- package/dist/audit/dependencies.d.ts +11 -0
- package/dist/audit/files.d.ts +13 -0
- package/dist/audit/format.d.ts +10 -0
- package/dist/audit/hygiene.d.ts +13 -0
- package/dist/audit/ignore.d.ts +11 -0
- package/dist/audit/run.d.ts +17 -0
- package/dist/audit/secrets.d.ts +11 -0
- package/dist/audit/types.d.ts +49 -0
- package/dist/audit/workflows.d.ts +6 -0
- package/dist/scan.d.ts +17 -0
- package/dist/scan.js +12 -0
- package/package.json +11 -5
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { Finding } from './types';
|
|
2
|
+
/** `// security-ignore: code/eval why it is fine` on a line, or on the one above it. */
|
|
3
|
+
export declare function inlineIgnore(lines: readonly string[], index: number): InlineIgnore | null;
|
|
4
|
+
/** Dangerous calls in a source file. Pure; `security-ignore` comments are applied by the caller. */
|
|
5
|
+
export declare function scanCode(path: string, text: string): Finding[];
|
|
6
|
+
export declare interface InlineIgnore { rule: string | null, reason: string }
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { Finding, ScanSkip } from './types';
|
|
2
|
+
/** `https://github.com/advisories/GHSA-xxxx-xxxx-xxxx` → `GHSA-xxxx-xxxx-xxxx`. */
|
|
3
|
+
export declare function advisoryId(url: string | undefined): string | undefined;
|
|
4
|
+
/** Turn `bun audit --json` output into findings. Pure. */
|
|
5
|
+
export declare function parseBunAudit(json: string): Finding[] | null;
|
|
6
|
+
/**
|
|
7
|
+
* Known vulnerabilities in the installed dependencies, from the registry's
|
|
8
|
+
* advisory database through `bun audit`. Returns the reason instead when it
|
|
9
|
+
* cannot run, so a scan never reports "clean" for a check that did not happen.
|
|
10
|
+
*/
|
|
11
|
+
export declare function auditDependencies(cwd: string, timeoutMs?: unknown): Promise<{ findings: Finding[] } | { skipped: ScanSkip }>;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export declare function isScannable(path: string): boolean;
|
|
2
|
+
/** Whether a file is a test, fixture or example, where fake credentials are normal. */
|
|
3
|
+
export declare function isTestPath(path: string): boolean;
|
|
4
|
+
/** Whether a file is documentation, where credentials are placeholders by convention. */
|
|
5
|
+
export declare function isDocPath(path: string): boolean;
|
|
6
|
+
/**
|
|
7
|
+
* The files a scan covers: what git tracks, so ignored files (`.env`, build
|
|
8
|
+
* output) are not reported as the project's own, or every file under the
|
|
9
|
+
* directory when it is not a git checkout. Project-relative, sorted.
|
|
10
|
+
*/
|
|
11
|
+
export declare function projectFiles(cwd: string): Promise<{ files: string[], tracked: boolean }>;
|
|
12
|
+
/** Only files this large or smaller are read. */
|
|
13
|
+
export declare const MAX_FILE_BYTES: unknown;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { Finding, ScanReport, Severity } from './types';
|
|
2
|
+
/** Whether the report has a finding at or above `threshold` (the exit code), or a broken exception file. */
|
|
3
|
+
export declare function failsScan(report: ScanReport, threshold: Severity | 'none'): boolean;
|
|
4
|
+
export declare function severityCounts(findings: readonly Finding[]): Record<Severity, number>;
|
|
5
|
+
/** The report as terminal text. */
|
|
6
|
+
export declare function formatScan(report: ScanReport, options?: FormatOptions): string;
|
|
7
|
+
export declare interface FormatOptions {
|
|
8
|
+
color?: boolean
|
|
9
|
+
showIgnored?: boolean
|
|
10
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Finding } from './types';
|
|
2
|
+
/**
|
|
3
|
+
* Names of the values in an env file that are readable secrets. Stacks encrypts
|
|
4
|
+
* env values (`encrypted:v2:…`, the DOTENV_PUBLIC_KEY line is public), so an
|
|
5
|
+
* encrypted `.env.production` is meant to be committed; a plaintext password
|
|
6
|
+
* in one is not.
|
|
7
|
+
*/
|
|
8
|
+
export declare function plaintextSecrets(envText: string): string[];
|
|
9
|
+
/**
|
|
10
|
+
* What the repository holds that it should not. `tracked` are the paths git
|
|
11
|
+
* tracks; `gitignore` is the root .gitignore's text, when there is one. Pure.
|
|
12
|
+
*/
|
|
13
|
+
export declare function scanHygiene(tracked: readonly string[], gitignore: string | null, hasPackageManifest: boolean, envContents?: Readonly<Record<string, string>>): Finding[];
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { Finding, IgnoreEntry } from './types';
|
|
2
|
+
/** Read and check `.security-ignore.json`: `{ "ignore": [{ rule, path?, reference?, reason, expires? }] }`. */
|
|
3
|
+
export declare function loadIgnores(cwd: string, today: string): Promise<LoadedIgnores>;
|
|
4
|
+
/** The entry that sets this finding aside, if any. */
|
|
5
|
+
export declare function matchingIgnore(finding: Finding, entries: readonly IgnoreEntry[]): IgnoreEntry | undefined;
|
|
6
|
+
/** The project's reviewed exceptions. */
|
|
7
|
+
export declare const IGNORE_FILE: '.security-ignore.json';
|
|
8
|
+
export declare interface LoadedIgnores {
|
|
9
|
+
entries: IgnoreEntry[]
|
|
10
|
+
problems: string[]
|
|
11
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { ScanOptions, ScanReport } from './types';
|
|
2
|
+
/**
|
|
3
|
+
* Scan a project for security problems. Checks:
|
|
4
|
+
*
|
|
5
|
+
* - `dependencies`: known vulnerabilities in the lockfile (`bun audit`)
|
|
6
|
+
* - `secrets`: credentials committed to files
|
|
7
|
+
* - `code`: calls that are dangerous when fed variable data (eval, shell
|
|
8
|
+
* strings, SQL interpolation, disabled TLS checks, ...)
|
|
9
|
+
* - `workflows`: GitHub Actions that run untrusted text or code with secrets
|
|
10
|
+
* - `hygiene`: committed `.env` files and key material, `.gitignore`
|
|
11
|
+
*
|
|
12
|
+
* Findings an exception in `.security-ignore.json` (with a reason, optionally
|
|
13
|
+
* an expiry) or a `security-ignore` comment sets aside are returned as
|
|
14
|
+
* `ignored`, never dropped silently. A check that cannot run (offline, no
|
|
15
|
+
* `bun audit`) is reported in `skipped`.
|
|
16
|
+
*/
|
|
17
|
+
export declare function runSecurityScan(options?: ScanOptions): Promise<ScanReport>;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { Finding } from './types';
|
|
2
|
+
/** Shannon entropy in bits per character. */
|
|
3
|
+
export declare function entropy(value: string): number;
|
|
4
|
+
/** `AKIA…Q7XZ`: enough to find it, never enough to use it. */
|
|
5
|
+
export declare function redact(value: string): string;
|
|
6
|
+
/**
|
|
7
|
+
* Credentials committed to a file. Pure. In tests, fixtures, examples and
|
|
8
|
+
* docs only the shapes that are never a placeholder are reported, at a lower
|
|
9
|
+
* severity; the generic `name = "value"` rule runs on real code only.
|
|
10
|
+
*/
|
|
11
|
+
export declare function scanSecrets(path: string, text: string): Finding[];
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
export declare function severityRank(severity: Severity): number;
|
|
2
|
+
/** Whether `severity` is at least as severe as `threshold`. */
|
|
3
|
+
export declare function atLeast(severity: Severity, threshold: Severity): boolean;
|
|
4
|
+
/** Most severe first. */
|
|
5
|
+
export declare const SEVERITIES: readonly Severity[];
|
|
6
|
+
export declare const SCAN_CHECKS: readonly ScanCheck[];
|
|
7
|
+
export declare interface Finding {
|
|
8
|
+
rule: string
|
|
9
|
+
check: ScanCheck
|
|
10
|
+
severity: Severity
|
|
11
|
+
title: string
|
|
12
|
+
file?: string
|
|
13
|
+
line?: number
|
|
14
|
+
detail?: string
|
|
15
|
+
fix?: string
|
|
16
|
+
reference?: string
|
|
17
|
+
package?: string
|
|
18
|
+
}
|
|
19
|
+
/** A reviewed exception, kept in `.security-ignore.json` with its reason. */
|
|
20
|
+
export declare interface IgnoreEntry {
|
|
21
|
+
rule: string
|
|
22
|
+
path?: string
|
|
23
|
+
reference?: string
|
|
24
|
+
reason: string
|
|
25
|
+
expires?: string
|
|
26
|
+
}
|
|
27
|
+
export declare interface ScanOptions {
|
|
28
|
+
cwd?: string
|
|
29
|
+
only?: readonly ScanCheck[]
|
|
30
|
+
skip?: readonly ScanCheck[]
|
|
31
|
+
offline?: boolean
|
|
32
|
+
today?: string
|
|
33
|
+
}
|
|
34
|
+
export declare interface ScanSkip {
|
|
35
|
+
check: ScanCheck
|
|
36
|
+
reason: string
|
|
37
|
+
}
|
|
38
|
+
export declare interface ScanReport {
|
|
39
|
+
cwd: string
|
|
40
|
+
checks: ScanCheck[]
|
|
41
|
+
skipped: ScanSkip[]
|
|
42
|
+
filesScanned: number
|
|
43
|
+
findings: Finding[]
|
|
44
|
+
ignored: Array<Finding & { reason: string }>
|
|
45
|
+
ignoreProblems: string[]
|
|
46
|
+
durationMs: number
|
|
47
|
+
}
|
|
48
|
+
export type Severity = 'critical' | 'high' | 'medium' | 'low';
|
|
49
|
+
export type ScanCheck = 'dependencies' | 'secrets' | 'code' | 'workflows' | 'hygiene';
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { Finding } from './types';
|
|
2
|
+
/*.yml`). Pure, line-based: the
|
|
3
|
+
* YAML shapes that turn a pull request, an issue title or a branch name into
|
|
4
|
+
* code running with the repository's secrets.
|
|
5
|
+
*/
|
|
6
|
+
export declare function scanWorkflow(path: string, text: string): Finding[];
|
package/dist/scan.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export type { Finding, IgnoreEntry, ScanCheck, ScanOptions, ScanReport, ScanSkip, Severity } from './audit/types';
|
|
2
|
+
/**
|
|
3
|
+
* `@stacksjs/security/scan`: the project security scan behind
|
|
4
|
+
* `buddy security:scan`. Kept off the package root because it reads the
|
|
5
|
+
* filesystem and spawns git and bun, which the crypto helpers must not pull
|
|
6
|
+
* into a browser bundle.
|
|
7
|
+
*/
|
|
8
|
+
export { scanCode, inlineIgnore } from './audit/code';
|
|
9
|
+
export { advisoryId, auditDependencies, parseBunAudit } from './audit/dependencies';
|
|
10
|
+
export { isDocPath, isScannable, isTestPath, projectFiles } from './audit/files';
|
|
11
|
+
export { failsScan, formatScan, severityCounts } from './audit/format';
|
|
12
|
+
export { plaintextSecrets, scanHygiene } from './audit/hygiene';
|
|
13
|
+
export { IGNORE_FILE, loadIgnores, matchingIgnore } from './audit/ignore';
|
|
14
|
+
export { runSecurityScan } from './audit/run';
|
|
15
|
+
export { entropy, redact, scanSecrets } from './audit/secrets';
|
|
16
|
+
export { atLeast, SCAN_CHECKS, SEVERITIES, severityRank } from './audit/types';
|
|
17
|
+
export { scanWorkflow } from './audit/workflows';
|
package/dist/scan.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
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};
|
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.
|
|
5
|
+
"version": "0.75.397",
|
|
6
6
|
"description": "The Stacks framework security.",
|
|
7
7
|
"author": "Chris Breuer",
|
|
8
8
|
"contributors": [
|
|
@@ -38,6 +38,12 @@
|
|
|
38
38
|
"import": "./dist/key.js",
|
|
39
39
|
"default": "./dist/key.js"
|
|
40
40
|
},
|
|
41
|
+
"./scan": {
|
|
42
|
+
"types": "./dist/scan.d.ts",
|
|
43
|
+
"bun": "./dist/scan.js",
|
|
44
|
+
"import": "./dist/scan.js",
|
|
45
|
+
"default": "./dist/scan.js"
|
|
46
|
+
},
|
|
41
47
|
"./*": {
|
|
42
48
|
"types": "./dist/*.d.ts",
|
|
43
49
|
"bun": "./dist/*.js",
|
|
@@ -63,13 +69,13 @@
|
|
|
63
69
|
"prepublishOnly": "bun run build"
|
|
64
70
|
},
|
|
65
71
|
"dependencies": {
|
|
66
|
-
"@stacksjs/config": "0.75.
|
|
72
|
+
"@stacksjs/config": "0.75.397",
|
|
67
73
|
"ts-security-crypto": "0.0.2"
|
|
68
74
|
},
|
|
69
75
|
"devDependencies": {
|
|
70
|
-
"@stacksjs/env": "0.75.
|
|
71
|
-
"@stacksjs/types": "0.75.
|
|
72
|
-
"@stacksjs/validation": "0.75.
|
|
76
|
+
"@stacksjs/env": "0.75.397",
|
|
77
|
+
"@stacksjs/types": "0.75.397",
|
|
78
|
+
"@stacksjs/validation": "0.75.397",
|
|
73
79
|
"better-dx": "^0.2.24"
|
|
74
80
|
}
|
|
75
81
|
}
|