@clear-capabilities/agentic-security-scanner 0.124.1 → 0.127.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +146 -0
- package/bin/agentic-security.js +75 -2
- package/dist/178.index.js +1 -1
- package/dist/220.index.js +193 -0
- package/dist/384.index.js +1 -1
- package/dist/435.index.js +2395 -0
- package/dist/449.index.js +135 -0
- package/dist/637.index.js +1 -1
- package/dist/752.index.js +7 -4
- package/dist/801.index.js +87 -0
- package/dist/838.index.js +1 -1
- package/dist/agentic-security.mjs +1 -1
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +5 -5
- package/src/engine.js +4 -0
- package/src/ir/CLAUDE.md +22 -17
- package/src/llm-validator/index.js +47 -12
- package/src/mcp/tools.js +97 -3
- package/src/posture/CLAUDE.md +3 -1
- package/src/posture/cache-economics.js +7 -4
- package/src/posture/deterministic-fix.js +65 -0
- package/src/posture/mttr.js +25 -0
- package/src/posture/provider-catalog.js +108 -0
- package/src/posture/secret-live-check.js +71 -0
- package/src/sast/CLAUDE.md +1 -1
- package/src/sast/api-authz.js +36 -0
- package/src/sast/file-upload.js +118 -0
- package/src/sast/llm-cost-advisor.js +88 -0
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// Live-secret validation (#22) — label a detected secret live | dead | unknown.
|
|
2
|
+
//
|
|
3
|
+
// "This Stripe/GitHub key is LIVE and was committed 40 commits ago" is a P0 the
|
|
4
|
+
// vibecoder must rotate now; "you have a high-entropy string" is noise. This
|
|
5
|
+
// closes that gap for the providers with a cheap, read-only "whoami" check.
|
|
6
|
+
//
|
|
7
|
+
// STRICTLY opt-in (a --validate-secrets flag / AGENTIC_SECURITY_VALIDATE_SECRETS)
|
|
8
|
+
// and OFFLINE-DEGRADING: any network error, timeout, or unrecognized provider
|
|
9
|
+
// yields 'unknown' — never a false 'dead'. No runtime cloud calls by default,
|
|
10
|
+
// per the scanner's no-network-by-default convention. The request builder is
|
|
11
|
+
// pure (no I/O) so it's testable without hitting a provider.
|
|
12
|
+
|
|
13
|
+
// Map a detected secret to a read-only validation request, or null when we have
|
|
14
|
+
// no safe check for that provider. Only providers whose token is a self-
|
|
15
|
+
// contained bearer/token credential (no signing, no extra params) are covered.
|
|
16
|
+
function buildLiveCheckRequest(secret) {
|
|
17
|
+
const val = (secret && (secret.match || secret.value || secret.secret || secret.token)) || '';
|
|
18
|
+
if (typeof val !== 'string' || val.length < 8) return null;
|
|
19
|
+
|
|
20
|
+
// GitHub PAT / OAuth token → GET /user (200 = live, 401 = dead).
|
|
21
|
+
if (/^gh[posru]_[A-Za-z0-9]{20,}$/.test(val) || /^github_pat_[A-Za-z0-9_]{20,}$/.test(val)) {
|
|
22
|
+
return { provider: 'github', method: 'GET', url: 'https://api.github.com/user',
|
|
23
|
+
headers: { Authorization: `token ${val}`, 'User-Agent': 'agentic-security', Accept: 'application/vnd.github+json' } };
|
|
24
|
+
}
|
|
25
|
+
// Stripe secret key → GET /v1/account (200 = live, 401 = dead).
|
|
26
|
+
if (/^sk_live_[A-Za-z0-9]{16,}$/.test(val) || /^rk_live_[A-Za-z0-9]{16,}$/.test(val)) {
|
|
27
|
+
return { provider: 'stripe', method: 'GET', url: 'https://api.stripe.com/v1/account',
|
|
28
|
+
headers: { Authorization: `Bearer ${val}` } };
|
|
29
|
+
}
|
|
30
|
+
// OpenAI key → GET /v1/models.
|
|
31
|
+
if (/^sk-[A-Za-z0-9]{20,}$/.test(val) && !/^sk_live_/.test(val)) {
|
|
32
|
+
return { provider: 'openai', method: 'GET', url: 'https://api.openai.com/v1/models',
|
|
33
|
+
headers: { Authorization: `Bearer ${val}` } };
|
|
34
|
+
}
|
|
35
|
+
// SendGrid key → GET /v3/scopes.
|
|
36
|
+
if (/^SG\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,}$/.test(val)) {
|
|
37
|
+
return { provider: 'sendgrid', method: 'GET', url: 'https://api.sendgrid.com/v3/scopes',
|
|
38
|
+
headers: { Authorization: `Bearer ${val}` } };
|
|
39
|
+
}
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Classify an HTTP status into a liveness verdict. 200-2xx = live; 401/403 =
|
|
44
|
+
// dead (rejected credential); anything else = unknown (rate-limit, 5xx, etc. —
|
|
45
|
+
// we don't know, so don't claim dead).
|
|
46
|
+
function classifyStatus(status) {
|
|
47
|
+
if (status >= 200 && status < 300) return 'live';
|
|
48
|
+
if (status === 401 || status === 403) return 'dead';
|
|
49
|
+
return 'unknown';
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Perform the validation. Returns { verdict: 'live'|'dead'|'unknown', provider }.
|
|
53
|
+
// Offline-degrading: on any error/timeout, verdict is 'unknown'.
|
|
54
|
+
export async function checkSecretLive(secret, { timeoutMs = 4000 } = {}) {
|
|
55
|
+
const req = buildLiveCheckRequest(secret);
|
|
56
|
+
if (!req) return { verdict: 'unknown', provider: null };
|
|
57
|
+
const ctrl = new AbortController();
|
|
58
|
+
const t = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
59
|
+
try {
|
|
60
|
+
const r = await fetch(req.url, { method: req.method, headers: req.headers, signal: ctrl.signal });
|
|
61
|
+
return { verdict: classifyStatus(r.status), provider: req.provider };
|
|
62
|
+
} catch {
|
|
63
|
+
return { verdict: 'unknown', provider: req.provider };
|
|
64
|
+
} finally {
|
|
65
|
+
clearTimeout(t);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Pure surfaces exposed for tests (no network) — kept off the public API so the
|
|
70
|
+
// dead-module guard doesn't flag them; `checkSecretLive` is the wired entry.
|
|
71
|
+
export const _internal = { buildLiveCheckRequest, classifyStatus };
|
package/src/sast/CLAUDE.md
CHANGED
|
@@ -23,7 +23,7 @@ SAST detector modules. Each file exports one or more `scan*()` functions returni
|
|
|
23
23
|
|
|
24
24
|
**Framework structural (taint-independent, JS/Py recall)** — `js-framework-structural.js` (Express/Koa/NestJS/TypeORM: SQLi via `.query`/`.execute` concat-template, koa-send path, `ctx.body` XSS, HttpService SSRF, deep-merge prototype pollution) `python-structural.js` (Flask `render_template_string` XSS/SSTI, Django `.raw`/`.extra` + `cursor.execute` SQLi — robust string-literal matching spans embedded quotes like `"… name = '" + x`; `open()`/`send_file` path traversal via concat/f-string, CWE-22 deferring to `dropGuardedFindings`), and `go-structural.js` (db query + `fmt.Sprintf`/concat SQLi, `os.Open` + concat/Sprintf path traversal). High precision: parameterized/escaped/`{{ }}`-Jinja/`%s`-placeholder forms do NOT match; SSRF/path findings defer to `engine.js dropGuardedFindings` (which also drops a reflected-XSS finding when the reflected value passed through a captured HTML escaper — a *discarded* `escapeHtml(s);` does not count — and skips already-`isSanitized` findings so the suppression pipeline keeps its bookkeeping).
|
|
25
25
|
|
|
26
|
-
**Cross-cutting vuln classes** — `authz.js`, `csrf.js` (POST/PUT/PATCH/DELETE state-changing routes without CSRF defence; defence-aware suppression covers Express/Fastify/Flask/Django/FastAPI/Spring/Symfony **and Go (gin/echo/mux), Rails routes, ASP.NET MVC** — recognizes `gorilla/csrf`/`protect_from_forgery`/`[ValidateAntiForgeryToken]` defences and exempts token-auth (`[ApiController]`, Bearer scheme); bare ASP.NET `[Authorize]` still flags as cookie auth is CSRF-vulnerable), `code-injection-multilang.js` (CWE-94 for Java/C#/Go/Kotlin — dynamic code/expression evaluators on a NON-LITERAL argument: javax.script `eval`, GroovyShell, Spring SpEL `parseExpression`, MVEL/OGNL, Roslyn `CSharpScript`, `DataTable.Compute`, yaegi `interp.Eval`, `text/template` Parse of a user-controlled body; literal arguments don't match. JS/Python/Ruby eval stay with the flow engine + per-language modules), `csv-injection.js` (formula injection into spreadsheet cells, CWE-1236), `secret-concat.js` (language-agnostic hardcoded-secret SPLIT across concatenated literals — `'AKIA' + 'IOSF…'` / `'ghp' + '_…'` / `'sk' + '_live_…'` — reassembled and matched against provider prefixes; complements the contiguous-token secrets scanner and the C#-only split-concat rule), `host-header.js`, `jndi.js`, `jwt-exp.js`, `ldap-injection.js` (CWE-90 across JS/Java/Python **and** PHP/Go/C#/Ruby/Kotlin — filter built by concat/interpolation; an inline call-guard and a file-level escape-API guard suppress `ldap_escape`/`EscapeFilter`/`escape_filter_chars`/`Net::LDAP::Filter`/`EqualityFilter` forms), `xpath-injection.js` (CWE-643 across Java/Python/JS **and** PHP/Go/Ruby/C#/Kotlin — XPath expression built by concat/interpolation: `DOMXPath->query`, `SelectNodes`, Nokogiri `.xpath`, htmlquery/xmlpath, `XPath.compile`; embedded-quote-tolerant literal matching; parameterized/variable-bound APIs and static literals don't match), `mass-assignment.js`, `mutation-xss.js`, `nosql-injection.js`, `prototype-pollution.js`, `response-splitting.js` (CWE-113 CRLF/header injection across JS/Python/Java/PHP/Go/Ruby/C#/Kotlin — a response header value set from a request source without stripping CR/LF; recognizes CRLF-strip sanitizers — `.replace(/[\r\n]/)`, chained `.replace("\r")`, Ruby `gsub`/`delete`, Go `strings.NewReplacer`, PHP `str_replace` — and a request-scope param heuristic for the JVM/C# single-file shape), `ssrf-cloud-metadata.js`, `xss-reflected-multilang.js` (cross-language reflected XSS for Go/Ruby/PHP/C#/Kotlin/Java — user input written into an HTML response via concat/interpolation, e.g. Java servlet `response.getWriter().write("<…" + q)`, with a per-language escaper exclusion so `htmlspecialchars`/`HtmlEncode`/`template.HTMLEscapeString`/ERB `<%= %>`/OWASP `Encode.forHtml` forms don't match; JS/Python XSS stays with the flow engine + framework structural detectors), `stored-taint.js` (second-order / stored injection — **opt-in** via `AGENTIC_SECURITY_STORED_TAINT=1`), `toctou.js`, `wrong-context-sanitizer.js` (HTML-entity encoder used in a URL context — wrong-context output encoding, CWE-79), `zip-slip.js
|
|
26
|
+
**Cross-cutting vuln classes** — `authz.js`, `csrf.js` (POST/PUT/PATCH/DELETE state-changing routes without CSRF defence; defence-aware suppression covers Express/Fastify/Flask/Django/FastAPI/Spring/Symfony **and Go (gin/echo/mux), Rails routes, ASP.NET MVC** — recognizes `gorilla/csrf`/`protect_from_forgery`/`[ValidateAntiForgeryToken]` defences and exempts token-auth (`[ApiController]`, Bearer scheme); bare ASP.NET `[Authorize]` still flags as cookie auth is CSRF-vulnerable), `code-injection-multilang.js` (CWE-94 for Java/C#/Go/Kotlin — dynamic code/expression evaluators on a NON-LITERAL argument: javax.script `eval`, GroovyShell, Spring SpEL `parseExpression`, MVEL/OGNL, Roslyn `CSharpScript`, `DataTable.Compute`, yaegi `interp.Eval`, `text/template` Parse of a user-controlled body; literal arguments don't match. JS/Python/Ruby eval stay with the flow engine + per-language modules), `csv-injection.js` (formula injection into spreadsheet cells, CWE-1236), `secret-concat.js` (language-agnostic hardcoded-secret SPLIT across concatenated literals — `'AKIA' + 'IOSF…'` / `'ghp' + '_…'` / `'sk' + '_live_…'` — reassembled and matched against provider prefixes; complements the contiguous-token secrets scanner and the C#-only split-concat rule), `host-header.js`, `jndi.js`, `jwt-exp.js`, `ldap-injection.js` (CWE-90 across JS/Java/Python **and** PHP/Go/C#/Ruby/Kotlin — filter built by concat/interpolation; an inline call-guard and a file-level escape-API guard suppress `ldap_escape`/`EscapeFilter`/`escape_filter_chars`/`Net::LDAP::Filter`/`EqualityFilter` forms), `xpath-injection.js` (CWE-643 across Java/Python/JS **and** PHP/Go/Ruby/C#/Kotlin — XPath expression built by concat/interpolation: `DOMXPath->query`, `SelectNodes`, Nokogiri `.xpath`, htmlquery/xmlpath, `XPath.compile`; embedded-quote-tolerant literal matching; parameterized/variable-bound APIs and static literals don't match), `mass-assignment.js`, `mutation-xss.js`, `nosql-injection.js`, `prototype-pollution.js`, `response-splitting.js` (CWE-113 CRLF/header injection across JS/Python/Java/PHP/Go/Ruby/C#/Kotlin — a response header value set from a request source without stripping CR/LF; recognizes CRLF-strip sanitizers — `.replace(/[\r\n]/)`, chained `.replace("\r")`, Ruby `gsub`/`delete`, Go `strings.NewReplacer`, PHP `str_replace` — and a request-scope param heuristic for the JVM/C# single-file shape), `ssrf-cloud-metadata.js`, `xss-reflected-multilang.js` (cross-language reflected XSS for Go/Ruby/PHP/C#/Kotlin/Java — user input written into an HTML response via concat/interpolation, e.g. Java servlet `response.getWriter().write("<…" + q)`, with a per-language escaper exclusion so `htmlspecialchars`/`HtmlEncode`/`template.HTMLEscapeString`/ERB `<%= %>`/OWASP `Encode.forHtml` forms don't match; JS/Python XSS stays with the flow engine + framework structural detectors), `stored-taint.js` (second-order / stored injection — **opt-in** via `AGENTIC_SECURITY_STORED_TAINT=1`), `toctou.js`, `wrong-context-sanitizer.js` (HTML-entity encoder used in a URL context — wrong-context output encoding, CWE-79), `zip-slip.js`, `file-upload.js` (CWE-434 unrestricted file upload for JS/Python — Multer configured with no `fileFilter`/`limits`, and a write whose destination is built from the client-supplied filename (`originalname`/`req.files.*.name`/`.filename`); suppressed by a `basename`/uuid/`secure_filename`/sanitizer in the window).
|
|
27
27
|
|
|
28
28
|
**Cloud/infra** — `db-rls.js` (Supabase RLS), `env-hygiene.js` (NEXT_PUBLIC_ leaks, .env.example real values), `mobile-manifest.js`, `pipeline.js` (CI/CD integrity), `rate-limit.js`, `webhook.js`.
|
|
29
29
|
|
package/src/sast/api-authz.js
CHANGED
|
@@ -34,6 +34,23 @@ function mk(r, kind, api, cwe, why) {
|
|
|
34
34
|
};
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
// #9 — CWE-306 missing authentication for a state-changing route.
|
|
38
|
+
function mk306(r) {
|
|
39
|
+
return {
|
|
40
|
+
id: `api-authz:missing-auth:${r.file}:${r.line}`,
|
|
41
|
+
severity: 'high',
|
|
42
|
+
file: r.file,
|
|
43
|
+
line: r.line || 0,
|
|
44
|
+
vuln: `Missing authentication for a state-changing route (${r.method} ${r.path})`,
|
|
45
|
+
cwe: '306',
|
|
46
|
+
family: 'broken-access-control',
|
|
47
|
+
parser: 'API-AUTHZ',
|
|
48
|
+
subfamily: 'missing-auth',
|
|
49
|
+
description: `${r.method} ${r.path} performs a destructive/state-changing operation with no authentication, while other routes in this app DO enforce it — so auth-detection demonstrably works here. An unauthenticated ${r.method} lets anyone invoke it (delete/modify another user's data).`,
|
|
50
|
+
remediation: 'Require authentication on this route (the same middleware/guard the app uses elsewhere) and, for object routes, verify the caller owns the target object.',
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
37
54
|
/**
|
|
38
55
|
* Cross-route analysis over the aggregated route inventory (aR).
|
|
39
56
|
* Pure: takes routes[], returns Finding[].
|
|
@@ -67,5 +84,24 @@ export function scanApiBrokenAuthz(routes) {
|
|
|
67
84
|
}
|
|
68
85
|
}
|
|
69
86
|
}
|
|
87
|
+
|
|
88
|
+
// #9 — CWE-306 app-level pass. If the app authenticates SOME route (so our
|
|
89
|
+
// auth-detection works for it), a destructive route left unauthenticated is a
|
|
90
|
+
// missing-auth bug even when its file-local siblings are also public (the
|
|
91
|
+
// in-file inconsistency rule above cannot see that case). Scoped to DELETE and
|
|
92
|
+
// id-taking PUT/PATCH — the least-ambiguous "should never be public" shapes —
|
|
93
|
+
// so intentionally-public POSTs (login / signup / webhooks) don't false-fire.
|
|
94
|
+
// `push` dedupes by file:line, so a route already flagged BFLA/BOLA above is
|
|
95
|
+
// not double-reported here.
|
|
96
|
+
const appHasAuth = routes.some((r) => r && r.hasAuth);
|
|
97
|
+
if (appHasAuth) {
|
|
98
|
+
for (const r of routes) {
|
|
99
|
+
if (!r || r.hasAuth || !r.file || r.path === '(file-based)') continue;
|
|
100
|
+
const destructive = r.method === 'DELETE'
|
|
101
|
+
|| ((r.method === 'PUT' || r.method === 'PATCH') && ID_PARAM.test(r.path || ''));
|
|
102
|
+
if (destructive) push(mk306(r));
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
70
106
|
return findings;
|
|
71
107
|
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// CWE-434 — Unrestricted file upload (#6). A whole CWE that had NO detector.
|
|
2
|
+
//
|
|
3
|
+
// Bread-and-butter for the vibecoder stacks (Next.js / Express / Supabase /
|
|
4
|
+
// Firebase / FastAPI / Flask): an upload endpoint that writes an attacker-
|
|
5
|
+
// supplied file without restricting its type/extension/size, or that uses the
|
|
6
|
+
// client-supplied filename as the on-disk destination (also CWE-22 path
|
|
7
|
+
// traversal — `../../x` in the filename escapes the upload dir).
|
|
8
|
+
//
|
|
9
|
+
// Precision is the whole game here (uploads are everywhere). Each rule fires
|
|
10
|
+
// only on a clear unrestricted shape and is suppressed by the standard guard:
|
|
11
|
+
// - Multer configured with NEITHER fileFilter NOR limits → unrestricted.
|
|
12
|
+
// - A write whose destination is built from the CLIENT filename
|
|
13
|
+
// (file.originalname / req.files.*.name / UploadFile.filename) with no
|
|
14
|
+
// sanitizer (basename / uuid / randomUUID / sanitize / whitelist) nearby.
|
|
15
|
+
// A validated upload (fileFilter+limits, or a generated/sanitized name) does
|
|
16
|
+
// NOT match.
|
|
17
|
+
import { blankComments } from './_comment-strip.js';
|
|
18
|
+
|
|
19
|
+
const JS_EXT = /\.(?:js|jsx|ts|tsx|mjs|cjs)$/i;
|
|
20
|
+
const PY_EXT = /\.py$/i;
|
|
21
|
+
|
|
22
|
+
const _lineOf = (raw, idx) => raw.substring(0, idx).split('\n').length;
|
|
23
|
+
const _snip = (raw, line) => (raw.split('\n')[line - 1] || '').trim().slice(0, 200);
|
|
24
|
+
// A sanitizer for the destination filename anywhere in the ±6-line window.
|
|
25
|
+
const NAME_SANITIZER = /\b(?:basename|randomUUID|uuidv4|uuid4|uuid\.v4|nanoid|sanitize[-_]?filename|sanitizeFilename|slugify|crypto\.random|secure_filename|werkzeug)\b/i;
|
|
26
|
+
|
|
27
|
+
function _window(raw, line, half = 6) {
|
|
28
|
+
const lines = raw.split('\n');
|
|
29
|
+
const start = Math.max(0, line - 1 - half);
|
|
30
|
+
const end = Math.min(lines.length, line - 1 + half);
|
|
31
|
+
return lines.slice(start, end).join('\n');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function mk(file, raw, line, sub, severity, vuln, description, remediation) {
|
|
35
|
+
return {
|
|
36
|
+
id: `file-upload:${sub}:${file}:${line}`,
|
|
37
|
+
severity, file, line,
|
|
38
|
+
vuln, cwe: 'CWE-434',
|
|
39
|
+
family: 'unrestricted-file-upload',
|
|
40
|
+
parser: 'FILE-UPLOAD',
|
|
41
|
+
subfamily: sub,
|
|
42
|
+
snippet: _snip(raw, line),
|
|
43
|
+
description,
|
|
44
|
+
remediation,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function scanJs(file, raw, code, out, seen) {
|
|
49
|
+
const push = (line, mkr) => { const k = `${line}`; if (seen.has(k)) return; seen.add(k); out.push(mkr); };
|
|
50
|
+
|
|
51
|
+
// 1) Multer with neither fileFilter nor limits → unrestricted upload config.
|
|
52
|
+
// Matches `multer()` and `multer({ ... })` whose options lack both guards.
|
|
53
|
+
const multerRe = /\bmulter\s*\(\s*(?:\)|\{([\s\S]*?)\}\s*\))/g;
|
|
54
|
+
let m;
|
|
55
|
+
while ((m = multerRe.exec(code))) {
|
|
56
|
+
const opts = m[1] || '';
|
|
57
|
+
if (/\bfileFilter\b/.test(opts) || /\blimits\b/.test(opts)) continue; // guarded
|
|
58
|
+
const line = _lineOf(raw, m.index);
|
|
59
|
+
push(line, mk(file, raw, line, 'multer-unrestricted', 'medium',
|
|
60
|
+
'Unrestricted file upload — Multer configured with no fileFilter and no limits',
|
|
61
|
+
'This Multer instance accepts any file of any size. An attacker can upload an executable, oversized, or malicious file (web shell, zip bomb).',
|
|
62
|
+
'Add a `fileFilter` that allow-lists MIME types / extensions and a `limits: { fileSize }` cap. Store uploads outside the web root and never serve them with their uploaded name.'));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// 2) Write whose destination is built from the CLIENT-supplied filename.
|
|
66
|
+
// e.g. path.join(dir, file.originalname) / req.files.x.mv('...'+req.files.x.name)
|
|
67
|
+
// Also CWE-22: `../../` in the filename escapes the upload dir.
|
|
68
|
+
const clientName = /\b(?:\w+\.originalname|req\.files(?:\.\w+|\[[^\]]+\])?\.name)\b/;
|
|
69
|
+
const writeSinks = [
|
|
70
|
+
/\.mv\s*\(/, // express-fileupload
|
|
71
|
+
/\b(?:fs\.)?(?:writeFile|writeFileSync|createWriteStream)\s*\(/,
|
|
72
|
+
/\bpath\.join\s*\(/, // building the dest path
|
|
73
|
+
];
|
|
74
|
+
const lines = code.split('\n');
|
|
75
|
+
for (let i = 0; i < lines.length; i++) {
|
|
76
|
+
if (!clientName.test(lines[i])) continue;
|
|
77
|
+
if (!writeSinks.some(re => re.test(lines[i]))) continue;
|
|
78
|
+
const line = i + 1;
|
|
79
|
+
if (NAME_SANITIZER.test(_window(raw, line))) continue; // sanitized/generated name → safe
|
|
80
|
+
push(line, mk(file, raw, line, 'client-filename-dest', 'high',
|
|
81
|
+
'Unrestricted file upload — client-supplied filename used as the write destination',
|
|
82
|
+
'The uploaded file is written using its client-controlled name. An attacker can choose the extension (upload `shell.php`) or embed path traversal (`../../etc/x`) to escape the upload directory.',
|
|
83
|
+
'Never trust the uploaded filename. Generate a server-side name (uuid/nanoid) and validate the extension against an allow-list; write with path.basename() into a fixed directory outside the web root.'));
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function scanPy(file, raw, code, out, seen) {
|
|
88
|
+
const push = (line, mkr) => { const k = `${line}`; if (seen.has(k)) return; seen.add(k); out.push(mkr); };
|
|
89
|
+
// Flask: request.files['x'].save(os.path.join(dir, file.filename))
|
|
90
|
+
// FastAPI: open(file.filename, ...) / shutil.copyfileobj(upload.file, open(upload.filename))
|
|
91
|
+
const clientName = /\b\w+\.filename\b/;
|
|
92
|
+
const writeSinks = [/\.save\s*\(/, /\bopen\s*\(/, /\bos\.path\.join\s*\(/, /\bcopyfileobj\s*\(/];
|
|
93
|
+
const lines = code.split('\n');
|
|
94
|
+
for (let i = 0; i < lines.length; i++) {
|
|
95
|
+
if (!clientName.test(lines[i])) continue;
|
|
96
|
+
if (!writeSinks.some(re => re.test(lines[i]))) continue;
|
|
97
|
+
const line = i + 1;
|
|
98
|
+
if (NAME_SANITIZER.test(_window(raw, line))) continue; // secure_filename / uuid → safe
|
|
99
|
+
push(line, mk(file, raw, line, 'client-filename-dest', 'high',
|
|
100
|
+
'Unrestricted file upload — client-supplied filename used as the write destination',
|
|
101
|
+
'The uploaded file is saved under its client-controlled name. An attacker can choose the extension or embed path traversal to escape the upload directory.',
|
|
102
|
+
'Use werkzeug secure_filename() (Flask) or generate a uuid name; validate the extension against an allow-list and write into a fixed directory outside the web root.'));
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function scanFileUpload(fp, raw) {
|
|
107
|
+
if (!raw || raw.length > 500_000) return [];
|
|
108
|
+
const isJs = JS_EXT.test(fp), isPy = PY_EXT.test(fp);
|
|
109
|
+
if (!isJs && !isPy) return [];
|
|
110
|
+
// Cheap relevance gate — skip files with no upload surface.
|
|
111
|
+
if (!/\b(?:multer|originalname|req\.files|UploadFile|\.filename|createWriteStream|\.mv\s*\()/i.test(raw)) return [];
|
|
112
|
+
const code = blankComments(raw, isPy ? 'py' : null);
|
|
113
|
+
const out = [];
|
|
114
|
+
const seen = new Set();
|
|
115
|
+
try { if (isJs) scanJs(fp, raw, code, out, seen); } catch { /* per-file best-effort */ }
|
|
116
|
+
try { if (isPy) scanPy(fp, raw, code, out, seen); } catch { /* per-file best-effort */ }
|
|
117
|
+
return out;
|
|
118
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// LLM cost + prompt-cache advisor (PRD CACHE_ECONOMICS_V2 — F1 cache-hygiene +
|
|
2
|
+
// P3 per-provider model/depth recommendation), as a SAST detector over the
|
|
3
|
+
// user's own LLM-calling code.
|
|
4
|
+
//
|
|
5
|
+
// Two rules, both gated on detecting an LLM provider in the file (low FP), both
|
|
6
|
+
// emitted at ADVISORY severity so they never inflate security counts:
|
|
7
|
+
//
|
|
8
|
+
// 1. cache-killer: a non-deterministic value (Date.now / uuid / datetime.now)
|
|
9
|
+
// interpolated into a prompt/system string — defeats prompt caching for the
|
|
10
|
+
// whole prefix after it (every provider).
|
|
11
|
+
// 2. over-provisioned: a flagship model used at a high reasoning depth — the
|
|
12
|
+
// catalog suggests a cheaper model + lower depth WITHIN the same provider.
|
|
13
|
+
//
|
|
14
|
+
// Provider/model/cache facts come from posture/provider-catalog.js (P1/P2).
|
|
15
|
+
import { blankComments } from './_comment-strip.js';
|
|
16
|
+
import { detectProvider, PROVIDERS, modelEntry, cheaperModel, depthAxis, cacheModel, SOURCED_AT } from '../posture/provider-catalog.js';
|
|
17
|
+
|
|
18
|
+
// How this provider's cache behaves — shapes the cache-killer remediation.
|
|
19
|
+
const CACHE_HINT = {
|
|
20
|
+
explicit: "Claude's prompt cache (set on a stable prefix via cache_control)",
|
|
21
|
+
automatic: 'the provider\'s automatic prompt cache (matches a ≥1024-token static prefix)',
|
|
22
|
+
'implicit-explicit': "Gemini's implicit/explicit context cache",
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const NONDET = /\b(?:Date\.now|new\s+Date|datetime\.now|datetime\.utcnow|time\.time|uuid4|uuidv4|crypto\.randomUUID|uuid\.uuid4|secrets\.token_hex|os\.urandom)\s*\(/;
|
|
26
|
+
const PROMPT_CTX = /\b(?:system|instructions?|developer|messages|prompt)\b|["']role["']\s*:\s*["'](?:system|developer)["']/i;
|
|
27
|
+
const EXPENSIVE_DEPTH = /(?:reasoning_effort|["']?effort["']?)\s*[:=]\s*["']?(?:high|xhigh|max)["']?|thinking[_]?budget\s*[:=]\s*\(?\s*(?:[1-9]\d{4,})/i;
|
|
28
|
+
|
|
29
|
+
const lineOf = (raw, idx) => raw.substring(0, idx).split('\n').length;
|
|
30
|
+
const snippetAt = (raw, line) => (raw.split('\n')[line - 1] || '').trim().slice(0, 200);
|
|
31
|
+
|
|
32
|
+
export function scanLlmCost(fp, raw) {
|
|
33
|
+
if (!/\.(?:js|jsx|ts|tsx|mjs|cjs|py|rb)$/i.test(fp)) return [];
|
|
34
|
+
if (!raw || raw.length > 500_000) return [];
|
|
35
|
+
const provider = detectProvider(raw);
|
|
36
|
+
if (!provider) return [];
|
|
37
|
+
const code = blankComments(raw);
|
|
38
|
+
const lines = code.split('\n');
|
|
39
|
+
const findings = [];
|
|
40
|
+
const seen = new Set();
|
|
41
|
+
const push = (f) => { if (!seen.has(f.id)) { seen.add(f.id); findings.push(f); } };
|
|
42
|
+
const pLabel = PROVIDERS[provider].label;
|
|
43
|
+
|
|
44
|
+
// ── Rule 1: non-deterministic content in a prompt-building string ──────────
|
|
45
|
+
for (let i = 0; i < lines.length; i++) {
|
|
46
|
+
if (!NONDET.test(lines[i])) continue;
|
|
47
|
+
// Require a prompt-context marker on the SAME line — the volatile value is
|
|
48
|
+
// being built into a system/prompt/messages string. (Same-line keeps FP low:
|
|
49
|
+
// a `datetime.now()` in a nearby log line must not trip on a `SYSTEM =` const
|
|
50
|
+
// three lines up.)
|
|
51
|
+
if (!PROMPT_CTX.test(lines[i])) continue;
|
|
52
|
+
const line = i + 1;
|
|
53
|
+
push({
|
|
54
|
+
id: `llm-cache-nondeterminism:${fp}:${line}`,
|
|
55
|
+
file: fp, line,
|
|
56
|
+
vuln: `Prompt-cache killer (cost advisory) — non-deterministic value in a ${pLabel} prompt prefix`,
|
|
57
|
+
severity: 'low', cwe: 'CWE-400', family: 'llm-cache', parser: 'LLM-COST', confidence: 0.6,
|
|
58
|
+
snippet: snippetAt(raw, line),
|
|
59
|
+
remediation: `A timestamp / UUID / random value in the cached prefix changes its bytes every request, so ${CACHE_HINT[cacheModel(provider)?.kind] || 'the prompt cache'} never hits and you pay full input price every call. Move the volatile value AFTER the stable prefix (or out of the prompt entirely) so the long shared prefix stays byte-identical and cacheable.`,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ── Rule 2: flagship model at high depth → recommend a cheaper option ──────
|
|
64
|
+
const expensiveModels = PROVIDERS[provider].models.filter(m => m.tier >= 2);
|
|
65
|
+
const dax = depthAxis(provider);
|
|
66
|
+
for (let i = 0; i < lines.length; i++) {
|
|
67
|
+
const m = expensiveModels.find(em => em.match.test(lines[i]));
|
|
68
|
+
if (!m) continue;
|
|
69
|
+
// Look for an expensive depth setting in the same call window (±6 lines).
|
|
70
|
+
const win = lines.slice(Math.max(0, i - 6), i + 7).join('\n');
|
|
71
|
+
if (!EXPENSIVE_DEPTH.test(win)) continue;
|
|
72
|
+
const line = i + 1;
|
|
73
|
+
const cheaper = cheaperModel(provider, m.id);
|
|
74
|
+
const alt = cheaper
|
|
75
|
+
? `${cheaper.id} at ${dax?.knob}=${dax?.cheap}`
|
|
76
|
+
: `a lower ${dax?.knob || 'reasoning depth'}`;
|
|
77
|
+
push({
|
|
78
|
+
id: `llm-overprovisioned:${fp}:${line}`,
|
|
79
|
+
file: fp, line,
|
|
80
|
+
vuln: `Over-provisioned model (cost advisory) — ${pLabel} flagship at high depth`,
|
|
81
|
+
severity: 'info', cwe: 'CWE-400', family: 'llm-cost', parser: 'LLM-COST', confidence: 0.5,
|
|
82
|
+
snippet: snippetAt(raw, line),
|
|
83
|
+
remediation: `This call pairs a flagship model (${m.id}) with a high ${dax?.knob || 'reasoning'} setting — the most expensive combination in ${pLabel}. If the task isn't intelligence-critical, try ${alt} first and measure: it can cut cost several-fold with little quality loss. Keep the flagship+high only where correctness clearly needs it. (Catalog pricing as of ${SOURCED_AT}.)`,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return findings;
|
|
88
|
+
}
|