@clear-capabilities/agentic-security-scanner 0.140.0 → 0.142.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 +283 -0
- package/dist/113.index.js +79 -3
- package/dist/178.index.js +1 -1
- package/dist/238.index.js +77 -1
- package/dist/384.index.js +1 -1
- package/dist/435.index.js +12 -0
- package/dist/526.index.js +79 -3
- package/dist/637.index.js +1 -1
- package/dist/agentic-security.mjs +14 -14
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/dist/compliance-frameworks/ccpa.json +34 -7
- package/dist/compliance-frameworks/eu-ai-act.json +65 -14
- package/dist/compliance-frameworks/gdpr.json +56 -12
- package/dist/compliance-frameworks/hipaa-security-rule.json +68 -15
- package/dist/compliance-frameworks/nist-ai-600-1.json +57 -12
- package/dist/compliance-frameworks/nist-csf-2.json +78 -16
- package/dist/compliance-frameworks/nist-privacy-1-1.json +3 -0
- package/dist/compliance-frameworks/owasp-asvs-5.json +91 -20
- package/dist/compliance-frameworks/owasp-llm-top-10.json +89 -20
- package/package.json +19 -5
- package/src/dataflow/CLAUDE.md +9 -0
- package/src/dataflow/catalog.js +61 -0
- package/src/dataflow/engine.js +95 -0
- package/src/dataflow/sanitizer-gate.js +61 -0
- package/src/engine.js +353 -31
- package/src/mcp/tools.js +12 -0
- package/src/posture/accuracy-scorecard.js +57 -0
- package/src/posture/aibom.js +110 -1
- package/src/posture/auditor-walkthrough.js +56 -17
- package/src/posture/compliance-frameworks/ccpa.json +34 -7
- package/src/posture/compliance-frameworks/eu-ai-act.json +65 -14
- package/src/posture/compliance-frameworks/gdpr.json +56 -12
- package/src/posture/compliance-frameworks/hipaa-security-rule.json +68 -15
- package/src/posture/compliance-frameworks/nist-ai-600-1.json +57 -12
- package/src/posture/compliance-frameworks/nist-csf-2.json +78 -16
- package/src/posture/compliance-frameworks/nist-privacy-1-1.json +3 -0
- package/src/posture/compliance-frameworks/owasp-asvs-5.json +91 -20
- package/src/posture/compliance-frameworks/owasp-llm-top-10.json +89 -20
- package/src/posture/concurrency-checker.js +3 -3
- package/src/posture/coverage-strength.js +182 -0
- package/src/posture/epss.js +17 -1
- package/src/posture/family-registry.js +103 -0
- package/src/posture/family-resolve.js +47 -0
- package/src/posture/fix-coverage.js +113 -0
- package/src/posture/fix-metrics.js +76 -0
- package/src/posture/mcp-rug-pull.js +144 -0
- package/src/posture/poc-inprocess.js +217 -1
- package/src/posture/proof-coverage.js +162 -0
- package/src/posture/reachability-filter.js +44 -0
- package/src/posture/sbom.js +12 -3
- package/src/runScan.js +56 -5
- package/src/sast/CLAUDE.md +2 -2
- package/src/sast/claude-md-prompt-injection.js +47 -3
- package/src/sast/cloud-iam.js +23 -0
- package/src/sast/convention-deviation.js +66 -3
- package/src/sast/crypto-protocol.js +23 -0
- package/src/sast/dapp-frontend.js +20 -0
- package/src/sast/iac-cloud-templates.js +346 -0
- package/src/sast/k8s-admission.js +27 -0
- package/src/sast/ml-supply-chain.js +22 -0
- package/src/sast/ruby.js +132 -0
- package/src/sast/web3-advanced.js +26 -0
- package/src/sca/CLAUDE.md +21 -4
- package/src/sca/container.js +18 -1
- package/src/sca/dep-confusion.js +69 -3
package/src/engine.js
CHANGED
|
@@ -15,6 +15,7 @@ import { scanLlmCost } from './sast/llm-cost-advisor.js';
|
|
|
15
15
|
import { scanBusinessLogic } from './sast/logic.js';
|
|
16
16
|
import { scanPipeline } from './sast/pipeline.js';
|
|
17
17
|
import { scanMCP } from './sast/mcp-audit.js';
|
|
18
|
+
import { detectRugPull as _detectRugPull, saveBaseline as _saveMcpBaseline, fingerprintConfig as _fingerprintMcp } from './posture/mcp-rug-pull.js';
|
|
18
19
|
import { scanClaudeSettings } from './sast/claude-settings.js';
|
|
19
20
|
import { scanClaudeMdPromptInjection } from './sast/claude-md-prompt-injection.js';
|
|
20
21
|
import { scanClaudeHookInjection } from './sast/claude-hook-injection.js';
|
|
@@ -36,8 +37,10 @@ import { scanMobileManifest } from './sast/mobile-manifest.js';
|
|
|
36
37
|
import { scanQuarkusHardening } from './sast/quarkus-hardening.js';
|
|
37
38
|
import { scanFastapiHardening } from './sast/fastapi-hardening.js';
|
|
38
39
|
import { isDeterministic } from './posture/deterministic.js';
|
|
40
|
+
import { proofCoverage } from './posture/proof-coverage.js';
|
|
39
41
|
import { scanAuthZ } from './sast/authz.js';
|
|
40
42
|
import { scanApiBrokenAuthz } from './sast/api-authz.js';
|
|
43
|
+
import { scanCloudTemplates, isCloudFormationTemplate } from './sast/iac-cloud-templates.js';
|
|
41
44
|
import { scanTerraform } from './sast/iac-terraform.js';
|
|
42
45
|
import { scanCrossService } from './sast/cross-service.js';
|
|
43
46
|
import { scanRbacConsistency } from './sast/rbac-consistency.js';
|
|
@@ -125,7 +128,7 @@ import { scanMutationXSS } from './sast/mutation-xss.js';
|
|
|
125
128
|
import { scanDeserializationGadgets, _detectGadgets } from './sast/deserialization-gadgets.js';
|
|
126
129
|
// Phase 2 — Kotlin / Ruby / PHP coverage.
|
|
127
130
|
import { scanKotlin } from './sast/kotlin.js';
|
|
128
|
-
import { scanRuby } from './sast/ruby.js';
|
|
131
|
+
import { scanRuby, scanRubyPathJoin } from './sast/ruby.js';
|
|
129
132
|
import { scanPhp } from './sast/php.js';
|
|
130
133
|
import { classifySecretCandidate as _entropyClassifySecret } from './sast/_secret-entropy.js';
|
|
131
134
|
// Phase 1 — precision-engineering posture modules.
|
|
@@ -665,6 +668,7 @@ function _isIaCFile(p){
|
|
|
665
668
|
if (IAC_FILENAMES.has(base)) return true;
|
|
666
669
|
if (/\.dockerfile$/i.test(base)) return true;
|
|
667
670
|
if (/\.tf$|\.tfvars$/i.test(base)) return true;
|
|
671
|
+
if (/\.bicep$/i.test(base)) return true;
|
|
668
672
|
// K8s YAML heuristic: under k8s/ — the CONTENT half of this rule lives in
|
|
669
673
|
// `isKubernetesManifest` below, because a path predicate cannot see content.
|
|
670
674
|
if (/(?:^|\/)k8s(?:\/|$)/.test(p) && /\.ya?ml$/i.test(base)) return true;
|
|
@@ -710,6 +714,27 @@ export function isKubernetesManifest(relPath, content) {
|
|
|
710
714
|
return _K8S_API_VERSION_RE.test(head) && _K8S_KIND_RE.test(head);
|
|
711
715
|
}
|
|
712
716
|
function getExt(n){const p=n.split(".");return p.length>1?p.pop().toLowerCase():"";}
|
|
717
|
+
|
|
718
|
+
// Agent instruction files (CLAUDE.md, AGENTS.md, .cursorrules, …) are admitted
|
|
719
|
+
// by CONTENT-INDEPENDENT NAME, the same way isKubernetesManifest admits a
|
|
720
|
+
// manifest a path predicate cannot recognise.
|
|
721
|
+
//
|
|
722
|
+
// They fail shouldScan() because they are markdown, so `fileContents` never held
|
|
723
|
+
// one, so scanClaudeMdPromptInjection / scanClaudeHookInjection / scanMCP at the
|
|
724
|
+
// per-file dispatch could never run on them. The detectors were CORRECT and
|
|
725
|
+
// fully tested when called directly — they were simply never called. That is the
|
|
726
|
+
// same dark-detector shape as k8s-admission and install-script, and it is worse
|
|
727
|
+
// here: for an agentic security tool a poisoned instruction file loaded into
|
|
728
|
+
// every session is a flagship threat, and a normal scan could not see it.
|
|
729
|
+
//
|
|
730
|
+
// Matched on the naming convention only. A repository's ordinary docs stay out
|
|
731
|
+
// of scope, so this admits a handful of files rather than every .md in the tree.
|
|
732
|
+
const _INSTRUCTION_FILE_ADMIT_RE = /(?:^|[\\/])(?:CLAUDE|AGENTS|GEMINI|CURSOR|CODEX|KIRO|QWEN|TRAE|OPENCODE|SYSTEM_PROMPT)\.(?:md|markdown|txt|prompt|system\.md)$|(?:^|[\\/])\.(?:cursorrules|windsurfrules|aiderrules)$|(?:^|[\\/])\.(?:claude|cursor|codex|gemini)[\\/].*\.(?:md|json|ya?ml)$/i;
|
|
733
|
+
|
|
734
|
+
export function isInstructionFile(relPath) {
|
|
735
|
+
return typeof relPath === 'string' && _INSTRUCTION_FILE_ADMIT_RE.test(relPath);
|
|
736
|
+
}
|
|
737
|
+
|
|
713
738
|
function shouldScan(p){if(/\.(test|spec|mock)\./i.test(p))return false;if(/_test\.go$/i.test(p))return false;if(/_spec\.rb$/i.test(p))return false;if(/Test\.(?:java|cs|kt|scala)$/i.test(p))return false;if(/\.min\.[mc]?js$/i.test(p))return false;for(const x of p.split("/"))if(IGNORE_DIRS.has(x))return false;
|
|
714
739
|
// Mobile + framework manifest files needed by the v4 detectors.
|
|
715
740
|
const base=p.split('/').pop();
|
|
@@ -1402,6 +1427,10 @@ function _sinkLineIdentifiers(ctx) {
|
|
|
1402
1427
|
// (excluding the sink line itself, which trivially contains its own
|
|
1403
1428
|
// argument). Tries every match, not just the first, since a window can
|
|
1404
1429
|
// contain several guard-shaped lines and only one need actually correlate.
|
|
1430
|
+
// A function/method declaration line, in every language this engine reads.
|
|
1431
|
+
// Matching a guard-shaped NAME here means a method is being defined, not called.
|
|
1432
|
+
const _DECL_LINE_RE = /^\s*(?:@\w+\s*)?(?:(?:public|private|protected|internal|static|final|abstract|override|async|export|default|func|fun|fn|def|sub|function)\s+)+[\w.<>\[\]]+\s*\(|^\s*def\s+\w|^\s*(?:async\s+)?function\s+\w|^\s*func\s+(?:\([^)]*\)\s*)?\w+\s*\(|^\s*(?:const|let|var)\s+\w+\s*=\s*(?:async\s*)?\([^)]*\)\s*=>/;
|
|
1433
|
+
|
|
1405
1434
|
function _guardMatchNearSinkIdentifier(ctx, guardRe, span = 2) {
|
|
1406
1435
|
const w = _guardWindow(ctx);
|
|
1407
1436
|
const re = new RegExp(guardRe.source, guardRe.flags.includes('g') ? guardRe.flags : guardRe.flags + 'g');
|
|
@@ -1412,6 +1441,19 @@ function _guardMatchNearSinkIdentifier(ctx, guardRe, span = 2) {
|
|
|
1412
1441
|
let m;
|
|
1413
1442
|
while ((m = re.exec(w))) {
|
|
1414
1443
|
const guardLineIdx = w.slice(0, m.index).split('\n').length - 1; // 0-based within window
|
|
1444
|
+
// A DECLARATION is not a guard. The shape alternative deliberately matches a
|
|
1445
|
+
// project-local validator by name (`[Cc]heck\w+\(`, `[Ee]nsure\w+\(`, …), and
|
|
1446
|
+
// that also matches the line that DEFINES such a method — so
|
|
1447
|
+
// `def check_static_cache(request)` was read as containment being applied,
|
|
1448
|
+
// and every path-traversal finding inside a method whose own name begins
|
|
1449
|
+
// "check"/"validate"/"ensure"/"verify"/"assert"/"require" was dropped.
|
|
1450
|
+
//
|
|
1451
|
+
// Found by test/detector-liveness.test.js on the ruby-path-join fixture: the
|
|
1452
|
+
// rule fired in isolation and produced nothing through a scan — the same
|
|
1453
|
+
// signature as rate-limit.js and sibling-guard-omission. Narrow by design:
|
|
1454
|
+
// it skips declaration LINES, and does not touch the window heuristic that
|
|
1455
|
+
// every other CWE-22 and CWE-918 emitter depends on.
|
|
1456
|
+
if (_DECL_LINE_RE.test(wLines[guardLineIdx] || '')) continue;
|
|
1415
1457
|
const lo = Math.max(0, guardLineIdx - span);
|
|
1416
1458
|
const hi = Math.min(wLines.length, guardLineIdx + span + 1);
|
|
1417
1459
|
const local = wLines.slice(lo, hi).filter((l) => l !== sinkLineText).join('\n');
|
|
@@ -1850,9 +1892,27 @@ const STRUCTURAL_VULN_PATTERNS=[
|
|
|
1850
1892
|
type:"File Serve",vuln:"Path Traversal (sendFile with User Input)",severity:"high",cwe:"CWE-22",stride:"Information Disclosure",
|
|
1851
1893
|
fix:"Allowlist file paths; never pass raw user input to sendFile"},
|
|
1852
1894
|
// ── Command Injection ──────────────────────────────────────────────────────
|
|
1853
|
-
|
|
1895
|
+
// SHELL-INVOKING forms only. `exec`/`execSync` hand the whole string to a
|
|
1896
|
+
// shell, so user input anywhere in the command is injection.
|
|
1897
|
+
//
|
|
1898
|
+
// `execFile`/`spawn` are DELIBERATELY NOT HERE. They take an argv array and
|
|
1899
|
+
// do not spawn a shell, so a tainted ARGUMENT cannot inject a command — they
|
|
1900
|
+
// are the canonical FIX for this very finding. The previous rule matched them
|
|
1901
|
+
// and rated it critical, while its own remediation text read "Use execFile
|
|
1902
|
+
// with argument array": following the advice could not clear the finding.
|
|
1903
|
+
// Flagging the fix as the bug is how a team learns to ignore a scanner.
|
|
1904
|
+
{regex:/(?:exec|execSync)\s*\([^;)]*(?:req\.|\.body\.|\.query\.|\.params\.)[^;)]{0,200}\)/g,
|
|
1854
1905
|
type:"OS Command",vuln:"Command Injection (User-Controlled Input)",severity:"critical",cwe:"CWE-78",stride:"Elevation of Privilege",
|
|
1855
1906
|
fix:"Use execFile with argument array; never interpolate user input into shell commands"},
|
|
1907
|
+
// The two ways an argv-form call IS still injectable:
|
|
1908
|
+
// 1. the tainted value is the COMMAND (first argument), not an argument to it
|
|
1909
|
+
// 2. `shell: true` is passed, which re-introduces the shell the argv form avoids
|
|
1910
|
+
{regex:/(?:execFile|spawn)(?:Sync)?\s*\(\s*[^,;)]*(?:req\.|\.body\.|\.query\.|\.params\.)[^,;)]{0,120}[,)]/g,
|
|
1911
|
+
type:"OS Command",vuln:"Command Injection (User-Controlled Command Name)",severity:"critical",cwe:"CWE-78",stride:"Elevation of Privilege",
|
|
1912
|
+
fix:"The COMMAND itself is user-controlled — an argv array does not help. Resolve the binary from a fixed allowlist."},
|
|
1913
|
+
{regex:/(?:execFile|spawn)(?:Sync)?\s*\([^;]{0,300}shell\s*:\s*true[^;]{0,200}(?:req\.|\.body\.|\.query\.|\.params\.)|(?:execFile|spawn)(?:Sync)?\s*\([^;]{0,300}(?:req\.|\.body\.|\.query\.|\.params\.)[^;]{0,200}shell\s*:\s*true/g,
|
|
1914
|
+
type:"OS Command",vuln:"Command Injection (argv form with shell:true)",severity:"critical",cwe:"CWE-78",stride:"Elevation of Privilege",
|
|
1915
|
+
fix:"`shell: true` re-introduces the shell that the argv array exists to avoid. Drop the option, or escape the input."},
|
|
1856
1916
|
{regex:/(?:vm\.runInContext|vm\.runInNewContext|new\s+vm\.Script)\s*\(/g,
|
|
1857
1917
|
type:"VM Sandbox",vuln:"VM Sandbox Execution (RCE Risk)",severity:"critical",cwe:"CWE-94",stride:"Elevation of Privilege",
|
|
1858
1918
|
fix:"Never execute user-supplied code in vm.runInContext; use a strict AST sandbox"},
|
|
@@ -6547,6 +6607,11 @@ function _annotateFunctionReachability(supplyChain, routes, callGraph, fc){
|
|
|
6547
6607
|
if (!sites.length) { sc.functionReachable = 'unknown'; sc.routeReachable = false; continue; }
|
|
6548
6608
|
let functionReachable = false;
|
|
6549
6609
|
let routeReachable = false;
|
|
6610
|
+
// Did ANY call site yield enough structure to reason about? If not, the
|
|
6611
|
+
// honest answer is `unknown` — see the verdict assignment below.
|
|
6612
|
+
let analysable = false;
|
|
6613
|
+
// At least one call site sits inside an exported function — see below.
|
|
6614
|
+
let publicApiSite = false;
|
|
6550
6615
|
for (const site of sites) {
|
|
6551
6616
|
// Classifier 1: site is inline inside a route handler (within 25 lines
|
|
6552
6617
|
// of the route def, no intervening function declaration). This is the
|
|
@@ -6569,8 +6634,16 @@ function _annotateFunctionReachability(supplyChain, routes, callGraph, fc){
|
|
|
6569
6634
|
// If any caller-chain hits a known route-handler function, the site
|
|
6570
6635
|
// is route-reachable-via-function. If no caller at all, we keep
|
|
6571
6636
|
// functionReachable=false for this site.
|
|
6572
|
-
const
|
|
6637
|
+
const encInfo = _enclosingFnInfo(fc[site.file] || '', site.line);
|
|
6638
|
+
const enclosing = encInfo && encInfo.name;
|
|
6639
|
+
// Not being able to name the enclosing function is INCONCLUSIVE, not
|
|
6640
|
+
// evidence of unreachability. Tracked so the verdict below can say so.
|
|
6573
6641
|
if (!enclosing) continue;
|
|
6642
|
+
// A public-API function with no in-tree caller is the NORMAL case, not
|
|
6643
|
+
// dead code: its callers are its users. Only a private function can be
|
|
6644
|
+
// shown unreachable by the absence of callers.
|
|
6645
|
+
if (encInfo.exported) { publicApiSite = true; continue; }
|
|
6646
|
+
analysable = true;
|
|
6574
6647
|
const callers = _reverseCallGraphReachable(callGraph, enclosing, 4);
|
|
6575
6648
|
if (callers.size > 1) functionReachable = true; // at least one caller exists
|
|
6576
6649
|
for (const callerFn of callers) {
|
|
@@ -6582,18 +6655,76 @@ function _annotateFunctionReachability(supplyChain, routes, callGraph, fc){
|
|
|
6582
6655
|
}
|
|
6583
6656
|
if (routeReachable) break;
|
|
6584
6657
|
}
|
|
6585
|
-
|
|
6658
|
+
// ABSENCE OF PROOF IS NOT PROOF OF ABSENCE.
|
|
6659
|
+
//
|
|
6660
|
+
// This read `functionReachable ? 'reachable' : 'unreachable'`, so a site the
|
|
6661
|
+
// analysis could not reason about — no recognisable enclosing function, no
|
|
6662
|
+
// routes in the project at all — was reported as UNREACHABLE and the finding
|
|
6663
|
+
// demoted to `info`. For a LIBRARY that is every site: express has no routes,
|
|
6664
|
+
// so `cookie` and `send`, both required at the top of `lib/response.js` and
|
|
6665
|
+
// called directly, were demoted out of the report.
|
|
6666
|
+
//
|
|
6667
|
+
// Measured by bench/sca-replay's reachability scorer (PRD F3.2): of the three
|
|
6668
|
+
// demotions it could adjudicate against an import-level oracle, ALL THREE were
|
|
6669
|
+
// false — express/cookie, express/send, poetry/requests, each genuinely
|
|
6670
|
+
// imported. A false `unreachable` is a MISSED EXPLOIT, the expensive
|
|
6671
|
+
// direction, so inconclusive now yields `unknown`: the finding keeps its
|
|
6672
|
+
// severity and the analysis stops making a claim it cannot support.
|
|
6673
|
+
// A project with NO ROUTES is a library, and "not reachable from any route"
|
|
6674
|
+
// is not a claim that can be made about one: its callers are its users, who
|
|
6675
|
+
// are not in this tree. express requires `cookie` and `send` at the top of
|
|
6676
|
+
// lib/response.js and calls both directly — there is simply no route to
|
|
6677
|
+
// trace them to, and reporting that as `unreachable` demoted two live
|
|
6678
|
+
// dependencies to `info`. Same shape for poetry/requests.
|
|
6679
|
+
//
|
|
6680
|
+
// This is the library-vs-application distinction the PRD already flags for
|
|
6681
|
+
// the taint engine (F2.3, "for a library the caller IS the attacker"),
|
|
6682
|
+
// showing up here as a false demotion instead of a false negative.
|
|
6683
|
+
const projectHasRoutes = Array.isArray(routes) && routes.length > 0;
|
|
6684
|
+
sc.functionReachable = functionReachable
|
|
6685
|
+
? 'reachable'
|
|
6686
|
+
: (analysable && projectHasRoutes && !publicApiSite ? 'unreachable' : 'unknown');
|
|
6586
6687
|
sc.routeReachable = routeReachable;
|
|
6587
6688
|
}
|
|
6588
6689
|
}
|
|
6589
|
-
function
|
|
6690
|
+
// The declaration form of the function enclosing `line`, scanning backwards.
|
|
6691
|
+
//
|
|
6692
|
+
// Recognises four shapes, not one. The original matched only `function name(`
|
|
6693
|
+
// and `const name = (`, so an ANONYMOUS function assigned to a member —
|
|
6694
|
+
// `res.cookie = function (name, value, options) {`, which is how most of
|
|
6695
|
+
// express, and most of the JS ecosystem, defines a public method — was invisible.
|
|
6696
|
+
// The scan then walked past it and attributed the call site to whatever
|
|
6697
|
+
// unrelated function appeared further up the file.
|
|
6698
|
+
//
|
|
6699
|
+
// `exported` matters as much as the name: a function assigned to a member or to
|
|
6700
|
+
// module.exports is PUBLIC API, so having no caller inside this repository is
|
|
6701
|
+
// the normal case and says nothing about whether it can be reached. Treating it
|
|
6702
|
+
// as evidence of unreachability is what demoted express's live `cookie`
|
|
6703
|
+
// dependency to `info`.
|
|
6704
|
+
function _enclosingFnInfo(content,line){
|
|
6590
6705
|
const lines=content.split('\n');
|
|
6591
6706
|
for(let i=line-2;i>=0;i--){
|
|
6592
|
-
const
|
|
6593
|
-
|
|
6707
|
+
const l=lines[i];
|
|
6708
|
+
let m=l.match(/(?:^|\s)(?:async\s+)?function\s+(\w+)\s*\(/);
|
|
6709
|
+
if(m)return {name:m[1],exported:/^\s*export\b/.test(l)};
|
|
6710
|
+
m=l.match(/^\s*(?:export\s+)?(?:const|let|var)\s+(\w+)\s*=\s*(?:async\s*)?\(/);
|
|
6711
|
+
if(m)return {name:m[1],exported:/^\s*export\b/.test(l)};
|
|
6712
|
+
// `X.y = function (…)` / `X.y = async (…) => ` / `module.exports.y = …`
|
|
6713
|
+
m=l.match(/^\s*(?:module\.)?(\w+)\.(\w+)\s*=\s*(?:async\s*)?(?:function\b|\()/);
|
|
6714
|
+
if(m)return {name:m[2],exported:true};
|
|
6715
|
+
// `module.exports = function name(…)`
|
|
6716
|
+
m=l.match(/^\s*module\.exports\s*=\s*(?:async\s*)?function\s*(\w*)\s*\(/);
|
|
6717
|
+
if(m)return {name:m[1]||'module.exports',exported:true};
|
|
6718
|
+
// Object-literal method / class method: `cookie: function (` or `cookie(a) {`
|
|
6719
|
+
m=l.match(/^\s*(\w+)\s*:\s*(?:async\s*)?(?:function\b|\()/);
|
|
6720
|
+
if(m)return {name:m[1],exported:false};
|
|
6594
6721
|
}
|
|
6595
6722
|
return null;
|
|
6596
6723
|
}
|
|
6724
|
+
function _enclosingFn(content,line){
|
|
6725
|
+
const info=_enclosingFnInfo(content,line);
|
|
6726
|
+
return info?info.name:null;
|
|
6727
|
+
}
|
|
6597
6728
|
|
|
6598
6729
|
// 0.6.0 Feat-2: Toxic-combinations score — composes multi-signal risk into 0–100.
|
|
6599
6730
|
// Composes existing per-finding signals into a 0–100 toxicity score with a
|
|
@@ -6851,6 +6982,19 @@ const CIPHER_TRANSIT_PATTERNS=[
|
|
|
6851
6982
|
function classifyCipherStrength(cipher){const c=cipher.toUpperCase();if(/\bRC4\b|\bRC2\b|\bARCFOUR\b|SSLV2|SSLV3|\bNULL\b|\bEXPORT\b|\bANULL\b|\bENULL\b|\bECB\b|\bMD5\b|\bSHA1\b(?![\d_])/.test(c))return"weak";if(/\bDES\b/.test(c)&&!/3DES|EDE|TRIPLE/.test(c))return"weak";if(/3DES|TRIPLE.?DES|DES.EDE|TLS.?1.?1|TLSV1\.1/.test(c))return"weak";if(/BCRYPT|ARGON2|SCRYPT|PBKDF2|FERNET|CHACHA20|CHACHAPOLY|PASSWORD_BCRYPT|PASSWORD_ARGON/.test(c))return"strong";if(/\bAES\b|SHA256|SHA384|SHA512|SHA3|BLAKE2|HMACSHA256|HMACSHA512|\bGCM\b|\bCCM\b|ECDHE|DHE|TLS.?1.?[23]|TLSV1\.[23]|HTTPS.SERVER|TLS.SERVER|TLS.CERTIF|HS256|RS256|ES256/.test(c))return"strong";return"unknown";}
|
|
6852
6983
|
function scanCiphers(fp,raw){const cleaned=stripNoise(raw,fp);const lines=raw.split("\n");const atRest=[],inTransit=[];for(const pat of CIPHER_REST_PATTERNS){const re=new RegExp(pat.regex.source,pat.regex.flags);let m;while((m=re.exec(cleaned))){const line=lineAt(cleaned,m.index);const cipher=pat.getLabel(m);atRest.push({cipher,strength:classifyCipherStrength(cipher),ctx:pat.ctx,file:fp,line,snippet:(lines[line-1]||"").trim()});}}for(const pat of CIPHER_TRANSIT_PATTERNS){const re=new RegExp(pat.regex.source,pat.regex.flags);let m;while((m=re.exec(cleaned))){const line=lineAt(cleaned,m.index);const cipher=pat.getLabel(m);inTransit.push({cipher,strength:classifyCipherStrength(cipher),ctx:pat.ctx,file:fp,line,snippet:(lines[line-1]||"").trim()});}}const uniq=(a)=>a.filter((v,i,arr)=>arr.findIndex(x=>x.cipher===v.cipher&&x.file===v.file&&x.line===v.line)===i);return{atRest:uniq(atRest),inTransit:uniq(inTransit)};}
|
|
6853
6984
|
|
|
6985
|
+
// True for the JWT specimen published in the standard's own documentation.
|
|
6986
|
+
// Decodes the payload rather than matching the encoded string, so a token that
|
|
6987
|
+
// merely shares a prefix is not suppressed.
|
|
6988
|
+
function _isSpecimenJwt(token){
|
|
6989
|
+
try{
|
|
6990
|
+
const parts=String(token).split('.');
|
|
6991
|
+
if(parts.length!==3)return false;
|
|
6992
|
+
const payload=Buffer.from(parts[1].replace(/-/g,'+').replace(/_/g,'/'),'base64').toString('utf8');
|
|
6993
|
+
const d=JSON.parse(payload);
|
|
6994
|
+
return d&&d.sub==='1234567890'&&typeof d.name==='string'&&d.name==='John Doe';
|
|
6995
|
+
}catch(_){return false;}
|
|
6996
|
+
}
|
|
6997
|
+
|
|
6854
6998
|
function scanCredentials(fp,raw){
|
|
6855
6999
|
if(!CRED_PREFILTER.test(raw))return[];
|
|
6856
7000
|
const lines=raw.split("\n");const results=[];const seen=new Set();
|
|
@@ -6860,11 +7004,23 @@ function scanCredentials(fp,raw){
|
|
|
6860
7004
|
while((m=re.exec(raw))!=null){
|
|
6861
7005
|
const val=m[0];
|
|
6862
7006
|
if(/placeholder|example|xxx+|your_|changeme|<[A-Z_]+>|MY_|INSERT_|REPLACE_|TODO|test_key|fake_|sample_|dummy_/i.test(val))continue;
|
|
7007
|
+
// The published specimen token, which appears verbatim in essentially
|
|
7008
|
+
// every piece of JWT documentation and in most auth tutorials. Its
|
|
7009
|
+
// payload decodes to {"sub":"1234567890","name":"John Doe",…} — a
|
|
7010
|
+
// documented example value in exactly the sense AKIAIOSFODNN7EXAMPLE is,
|
|
7011
|
+
// and suppressed for the same reason and just as narrowly: the check is
|
|
7012
|
+
// on the DECODED payload, so a real token that merely resembles it is
|
|
7013
|
+
// unaffected. Found by bench/secrets-precision as the single false
|
|
7014
|
+
// positive in its negative set.
|
|
7015
|
+
if(pat.n==="Exposed JWT Token"&&_isSpecimenJwt(val))continue;
|
|
6863
7016
|
const line=raw.substring(0,m.index).split("\n").length;
|
|
6864
7017
|
const snippet=lines[line-1]?.trim()||"";
|
|
6865
7018
|
// Per-pattern line-context gate: if ctx is set, the matched line must satisfy it
|
|
6866
7019
|
if(pat.ctx&&!pat.ctx.test(snippet))continue;
|
|
6867
|
-
|
|
7020
|
+
// The same placeholder-credential guard now covers every URI-with-inline-
|
|
7021
|
+
// credentials pattern, not just the one it was written for. A connection
|
|
7022
|
+
// string pointing at localhost with `user:pass` is a README, not a leak.
|
|
7023
|
+
if((pat.n==="Password in URL"||pat.urlCreds)&&/localhost|127\.0\.|0\.0\.0\.0|example\.com|test\.com|::1|user:pass|admin:admin|foo:bar|user:password|username:password|admin:password|root:password|test:test|john:doe|demo:demo|myuser:mypass|guest:guest/i.test(val))continue;
|
|
6868
7024
|
const key=`${fp}:${line}:${pat.n}`;
|
|
6869
7025
|
if(seen.has(key))continue;seen.add(key);
|
|
6870
7026
|
const severity=pat.s==="c"?"critical":pat.s==="h"?"high":"medium";
|
|
@@ -6873,10 +7029,18 @@ function scanCredentials(fp,raw){
|
|
|
6873
7029
|
// unredacted-snippet leak as scanEntropySecrets — `snippet` carried
|
|
6874
7030
|
// the raw source line (full credential value) straight through to
|
|
6875
7031
|
// every report format. Redact the exact matched value here too.
|
|
6876
|
-
results.push({vuln:pat.n,severity,cwe:"CWE-798",stride:"Information Disclosure",file:fp,line,snippet:snippet.split(val).join(masked),masked,fix:"Remove the hardcoded credential. Store secrets in environment variables or a secrets manager (AWS Secrets Manager, HashiCorp Vault, GCP Secret Manager). Rotate the exposed credential immediately, treat it as compromised.",code:`// Remove hardcoded value:\n// const secret = "${masked}";\n\n// Use environment variable instead:\nconst secret = process.env.${pat.n.toUpperCase().replace(/[^A-Z0-9]/g,"_")};`});
|
|
7032
|
+
results.push({vuln:pat.n,severity,cwe:"CWE-798",stride:"Information Disclosure",file:fp,line,_urlCreds:!!pat.urlCreds,snippet:snippet.split(val).join(masked),masked,fix:"Remove the hardcoded credential. Store secrets in environment variables or a secrets manager (AWS Secrets Manager, HashiCorp Vault, GCP Secret Manager). Rotate the exposed credential immediately, treat it as compromised.",code:`// Remove hardcoded value:\n// const secret = "${masked}";\n\n// Use environment variable instead:\nconst secret = process.env.${pat.n.toUpperCase().replace(/[^A-Z0-9]/g,"_")};`});
|
|
6877
7033
|
}
|
|
6878
7034
|
}
|
|
6879
|
-
|
|
7035
|
+
// One secret, one finding. A `postgres://user:pass@host/db` matches both the
|
|
7036
|
+
// specific PostgreSQL pattern and the generic "Password in URL" one, and
|
|
7037
|
+
// reporting the same credential on the same line twice is noise that makes a
|
|
7038
|
+
// secrets report look padded. The specific name wins: it tells the reader
|
|
7039
|
+
// which system to go and rotate.
|
|
7040
|
+
const specificUrlLines=new Set(results.filter(r=>r._urlCreds).map(r=>`${r.file}:${r.line}`));
|
|
7041
|
+
const deduped=results.filter(r=>!(r.vuln==="Password in URL"&&specificUrlLines.has(`${r.file}:${r.line}`)));
|
|
7042
|
+
for(const r of deduped)delete r._urlCreds;
|
|
7043
|
+
return deduped;
|
|
6880
7044
|
}
|
|
6881
7045
|
|
|
6882
7046
|
/* ── OSV-backed SCA Engine ───────────────────────────────────────────────── */
|
|
@@ -6977,16 +7141,60 @@ async function _enrichWithEPSS(supplyChainResults){
|
|
|
6977
7141
|
const _KEV_FEED_URL = 'https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json';
|
|
6978
7142
|
const _KEV_TTL_MS = 24 * 60 * 60 * 1000;
|
|
6979
7143
|
|
|
7144
|
+
// PRD F3.4 — a KEV catalog has no meaning without its age.
|
|
7145
|
+
//
|
|
7146
|
+
// The refresh TTL above only decides when to TRY the network. Every failure
|
|
7147
|
+
// path below falls back to `cached?.byCve` with NO age bound, so an offline
|
|
7148
|
+
// machine, a blocked egress rule or a CISA outage silently serves a catalog of
|
|
7149
|
+
// any age. A six-month-old catalog does not fail loudly — it quietly omits
|
|
7150
|
+
// every vulnerability added since, which UNDERSTATES risk. That is the worst
|
|
7151
|
+
// direction for this particular signal: KEV membership is used to escalate.
|
|
7152
|
+
//
|
|
7153
|
+
// The catalog is still used when stale (dropping it would understate risk even
|
|
7154
|
+
// harder), but its age is recorded and surfaced on the scan so a report can
|
|
7155
|
+
// state it, and `staleness` is a first-class value rather than an inference.
|
|
7156
|
+
const _KEV_STALE_AFTER_MS = 7 * 24 * 60 * 60 * 1000;
|
|
7157
|
+
|
|
7158
|
+
// Populated by _loadKEVCatalog and read when the scan result is assembled.
|
|
7159
|
+
let _kevCatalogMeta = { source: 'not-loaded', fetchedAt: null, ageDays: null, stale: null, entries: 0 };
|
|
7160
|
+
export function kevCatalogMeta() { return { ..._kevCatalogMeta }; }
|
|
7161
|
+
|
|
7162
|
+
function _setKevMeta(source, ts, entries) {
|
|
7163
|
+
const ageMs = ts ? Date.now() - ts : null;
|
|
7164
|
+
_kevCatalogMeta = {
|
|
7165
|
+
source,
|
|
7166
|
+
fetchedAt: ts ? new Date(ts).toISOString() : null,
|
|
7167
|
+
ageDays: ageMs == null ? null : Math.floor(ageMs / 86400000),
|
|
7168
|
+
stale: ageMs == null ? null : ageMs > _KEV_STALE_AFTER_MS,
|
|
7169
|
+
entries: entries || 0,
|
|
7170
|
+
meaning: 'KEV membership escalates severity. A stale catalog omits recently-added CVEs, so it understates risk rather than overstating it.',
|
|
7171
|
+
};
|
|
7172
|
+
}
|
|
7173
|
+
|
|
6980
7174
|
async function _loadKEVCatalog(){
|
|
6981
|
-
if (process.env.AGENTIC_SECURITY_OFFLINE === '1')
|
|
7175
|
+
if (process.env.AGENTIC_SECURITY_OFFLINE === '1') {
|
|
7176
|
+
_setKevMeta('offline-skipped', null, 0);
|
|
7177
|
+
return null;
|
|
7178
|
+
}
|
|
6982
7179
|
// Cached blob: { ts, byCve: { 'CVE-XXXX-YYYY': { dateAdded, ransomwareCampaign, vendor, product, vuln, action } } }
|
|
6983
7180
|
const cached = _osvCacheGet('kev:catalog');
|
|
6984
|
-
if (cached && cached.ts && (Date.now() - cached.ts < _KEV_TTL_MS))
|
|
7181
|
+
if (cached && cached.ts && (Date.now() - cached.ts < _KEV_TTL_MS)) {
|
|
7182
|
+
_setKevMeta('cache-fresh', cached.ts, Object.keys(cached.byCve || {}).length);
|
|
7183
|
+
return cached.byCve || null;
|
|
7184
|
+
}
|
|
7185
|
+
const fallback = () => {
|
|
7186
|
+
if (cached && cached.byCve) {
|
|
7187
|
+
_setKevMeta('cache-stale', cached.ts || null, Object.keys(cached.byCve).length);
|
|
7188
|
+
return cached.byCve;
|
|
7189
|
+
}
|
|
7190
|
+
_setKevMeta('unavailable', null, 0);
|
|
7191
|
+
return null;
|
|
7192
|
+
};
|
|
6985
7193
|
try {
|
|
6986
7194
|
const res = await fetch(_KEV_FEED_URL, {
|
|
6987
7195
|
headers: { 'User-Agent': 'agentic-security/0.1' },
|
|
6988
7196
|
});
|
|
6989
|
-
if (!res.ok) return
|
|
7197
|
+
if (!res.ok) return fallback();
|
|
6990
7198
|
const j = await res.json();
|
|
6991
7199
|
const byCve = {};
|
|
6992
7200
|
for (const v of (j.vulnerabilities || [])) {
|
|
@@ -7001,9 +7209,11 @@ async function _loadKEVCatalog(){
|
|
|
7001
7209
|
dueDate: v.dueDate || null,
|
|
7002
7210
|
};
|
|
7003
7211
|
}
|
|
7004
|
-
|
|
7212
|
+
const ts = Date.now();
|
|
7213
|
+
_osvCacheSet('kev:catalog', { ts, byCve });
|
|
7214
|
+
_setKevMeta('network', ts, Object.keys(byCve).length);
|
|
7005
7215
|
return byCve;
|
|
7006
|
-
} catch { return
|
|
7216
|
+
} catch { return fallback(); }
|
|
7007
7217
|
}
|
|
7008
7218
|
|
|
7009
7219
|
async function _enrichWithKEV(supplyChainResults){
|
|
@@ -7155,7 +7365,14 @@ function _parseGoMod(text,filePath){
|
|
|
7155
7365
|
if(t===')'){inReq=false;continue;}
|
|
7156
7366
|
let m=inReq?t.match(/^([^\s]+)\s+v([^\s/]+)/):t.match(/^require\s+([^\s]+)\s+v([^\s/]+)/);
|
|
7157
7367
|
if(m){
|
|
7158
|
-
|
|
7368
|
+
// Keep the version VERBATIM. This used to do `.replace(/-.*$/,'')`,
|
|
7369
|
+
// which turns every Go pseudo-version — v0.0.0-20210903162142-ad29c8ab022f
|
|
7370
|
+
// — into a bare `0.0.0`. That is not a shorter version, it is a different
|
|
7371
|
+
// and nonexistent one, and it made every pseudo-versioned module in a tree
|
|
7372
|
+
// collapse onto the same key. Normalisation for an advisory query belongs
|
|
7373
|
+
// at the query, where _osvQueryVersion does it; a component's recorded
|
|
7374
|
+
// version is also what lands in the SBOM, where truncating it is worse.
|
|
7375
|
+
const name=m[1];const ver=m[2];
|
|
7159
7376
|
const isIndirect=t.includes('// indirect');
|
|
7160
7377
|
out.push({name,version:ver,group:name.split('/').slice(0,2).join('/'),
|
|
7161
7378
|
scope:isIndirect?'optional':'required',purl:_makePurl('golang',name,ver,''),
|
|
@@ -7550,8 +7767,10 @@ function _parseGoSum(text, filePath){
|
|
|
7550
7767
|
const m = t.match(/^(\S+)\s+v([^\s]+)\s+h1:/);
|
|
7551
7768
|
if (!m) continue;
|
|
7552
7769
|
const name = m[1];
|
|
7553
|
-
//
|
|
7554
|
-
|
|
7770
|
+
// Verbatim, minus the leading `v`. The suffixes this used to strip
|
|
7771
|
+
// (`+incompatible`, the pseudo-version timestamp+sha) are part of the
|
|
7772
|
+
// module version the advisory database matches on — see _parseGoMod.
|
|
7773
|
+
const ver = m[2].replace(/^v/, '');
|
|
7555
7774
|
const dedupKey = `${name}@${ver}`;
|
|
7556
7775
|
if (seen.has(dedupKey)) continue;
|
|
7557
7776
|
seen.add(dedupKey);
|
|
@@ -7646,11 +7865,20 @@ function parseManifests(allFileContents){
|
|
|
7646
7865
|
// R10: Gradle resolved transitive graph — `gradle dependencies > gradle-dependencies.txt`.
|
|
7647
7866
|
'gradle-dependencies.txt':_parseGradleDependencies,
|
|
7648
7867
|
};
|
|
7868
|
+
// Requirements files are named a dozen ways and the basename table can only
|
|
7869
|
+
// hold one of them. `requirements/dev.txt`, `requirements-dev.txt` and
|
|
7870
|
+
// `requirements/base.txt` are all ordinary; matched by SHAPE so a new variant
|
|
7871
|
+
// does not need a new table entry. Kept narrow on purpose — an arbitrary
|
|
7872
|
+
// `.txt` reaching this parser would invent dependencies out of prose.
|
|
7873
|
+
const _REQ_FILE=/^requirements(?:[._-][\w.-]+)?\.txt$/i;
|
|
7874
|
+
const _REQ_DIR=/(?:^|\/)requirements\/[\w.-]+\.txt$/i;
|
|
7875
|
+
const _pick=(fp,base)=>PARSERS[base]||((_REQ_FILE.test(base)||_REQ_DIR.test(fp))?_parseRequirementsTxt:null);
|
|
7649
7876
|
const out=[],seen=new Set();
|
|
7650
7877
|
for(const[fp,content]of Object.entries(allFileContents)){
|
|
7651
7878
|
const base=fp.split('/').pop();
|
|
7652
|
-
|
|
7653
|
-
|
|
7879
|
+
const parser=_pick(fp.split('\\').join('/'),base);
|
|
7880
|
+
if(!parser)continue;
|
|
7881
|
+
for(const comp of parser(content,fp)){
|
|
7654
7882
|
const key=`${comp.ecosystem}:${comp.name}:${comp.version}`;
|
|
7655
7883
|
if(!seen.has(key)){seen.add(key);out.push(comp);}
|
|
7656
7884
|
}
|
|
@@ -7728,6 +7956,34 @@ function computeAttackPathComponents(findings,components,byFile){
|
|
|
7728
7956
|
return{flagged,pathsByKey};
|
|
7729
7957
|
}
|
|
7730
7958
|
|
|
7959
|
+
// The version string an advisory database can actually match on.
|
|
7960
|
+
//
|
|
7961
|
+
// This used to be `version.match(/(\d+\.\d+(?:\.\d+)*)/)`, which takes the
|
|
7962
|
+
// first dotted-number run and throws the rest away. For most ecosystems that is
|
|
7963
|
+
// harmless; for Go it is destructive. A Go pseudo-version is
|
|
7964
|
+
//
|
|
7965
|
+
// v0.0.0-20210903162142-ad29c8ab022f
|
|
7966
|
+
//
|
|
7967
|
+
// and the leading `0.0.0` is a placeholder, not a version — every pseudo-version
|
|
7968
|
+
// in the tree collapsed to the same meaningless `0.0.0`, so the query asked
|
|
7969
|
+
// about a release that does not exist and the real one was never checked.
|
|
7970
|
+
// bench/sca-replay attributed nearly every remaining Go miss to exactly this.
|
|
7971
|
+
// `+incompatible` builds lost their suffix the same way.
|
|
7972
|
+
//
|
|
7973
|
+
// A WILDCARD is refused outright rather than truncated. `2.0.*` is a range; it
|
|
7974
|
+
// has no single version to be affected, and reporting "phpseclib 2.0.* is
|
|
7975
|
+
// vulnerable" names something that was never installed.
|
|
7976
|
+
function _osvQueryVersion(raw){
|
|
7977
|
+
const s=String(raw||'').trim();
|
|
7978
|
+
if(!s)return null;
|
|
7979
|
+
if(/[*x]/i.test(s.replace(/^[\^~>=<\s]+/,'').replace(/[-+][\w.-]+$/,'')))return null;
|
|
7980
|
+
// Strip only a leading range operator or `v`; keep the whole version after it.
|
|
7981
|
+
const m=s.match(/^[\^~>=<\s]*v?(\d[\w.+-]*)$/);
|
|
7982
|
+
if(m)return m[1];
|
|
7983
|
+
const fallback=s.match(/(\d+\.\d+(?:\.\d+)*)/);
|
|
7984
|
+
return fallback?fallback[1]:null;
|
|
7985
|
+
}
|
|
7986
|
+
|
|
7731
7987
|
async function queryOSV(components,allFileContents){
|
|
7732
7988
|
const OSV_ECO={npm:'npm',pypi:'PyPI',packagist:'Packagist',rubygems:'RubyGems',golang:'Go',cargo:'crates.io',maven:'Maven',pub:'Pub'};
|
|
7733
7989
|
const results=[];
|
|
@@ -7742,7 +7998,7 @@ async function queryOSV(components,allFileContents){
|
|
|
7742
7998
|
const queries=[],uncached=[],vulnAffects={};
|
|
7743
7999
|
for(const comp of queryable){
|
|
7744
8000
|
const eco=OSV_ECO[comp.ecosystem];
|
|
7745
|
-
const cleanVer=(comp.version
|
|
8001
|
+
const cleanVer=_osvQueryVersion(comp.version);
|
|
7746
8002
|
if(!cleanVer)continue;
|
|
7747
8003
|
const ck=`comp:${eco}:${comp.name}:${cleanVer}`;
|
|
7748
8004
|
const cached=_osvCacheGet(ck);
|
|
@@ -8039,7 +8295,7 @@ function _deterministicFileTimings(timings) {
|
|
|
8039
8295
|
|
|
8040
8296
|
const _fileTimings = [];
|
|
8041
8297
|
let _filesSkipped = 0, _filesTimedOut = 0, _filesDenseSkipped = 0;
|
|
8042
|
-
const files=Object.keys(fileContents).filter(f=>(shouldScan(f) || isKubernetesManifest(f, fileContents[f])) && !_isPathIgnored(f));const fc={},pfr={};const aR=[],aF=[],aSrc=[],aSink=[],aSan=[],aLogic=[],aSupply=[],aSecrets=[],aCiphersRest=[],aCiphersTransit=[];
|
|
8298
|
+
const files=Object.keys(fileContents).filter(f=>(shouldScan(f) || isKubernetesManifest(f, fileContents[f]) || isCloudFormationTemplate(f, fileContents[f]) || isInstructionFile(f)) && !_isPathIgnored(f));const fc={},pfr={};const aR=[],aF=[],aSrc=[],aSink=[],aSan=[],aLogic=[],aSupply=[],aSecrets=[],aCiphersRest=[],aCiphersTransit=[];
|
|
8043
8299
|
// ---- R8: opt-in per-file checkpointing (AGENTIC_SECURITY_RESUME=1, or
|
|
8044
8300
|
// runScan({resume:true})). Default OFF, so existing behaviour is untouched.
|
|
8045
8301
|
// Only this loop is checkpointed; every cross-file pass below re-runs, so
|
|
@@ -8107,7 +8363,7 @@ function _deterministicFileTimings(timings) {
|
|
|
8107
8363
|
let i=0;for(const p of files){i++;const _ft0=Date.now();setProgress({current:i,total:files.length,file:p.split("/").pop(),phase:"Scanning"});
|
|
8108
8364
|
if(_ckptDone.has(p)&&_ckptReplay(p))continue;
|
|
8109
8365
|
const _mk={aR:aR.length,aF:aF.length,aSrc:aSrc.length,aSink:aSink.length,aSan:aSan.length,aLogic:aLogic.length,aSecrets:aSecrets.length,aCR:aCiphersRest.length,aCT:aCiphersTransit.length,sup:_suppressionLog.length};
|
|
8110
|
-
try{const c=fileContents[p];if(!c||c.length>500000){_filesSkipped++;continue;}const _avgLine=c.length/Math.max(c.split('\n').length,1);if(_avgLine>400&&c.length>10000){_filesDenseSkipped++;continue;}const cc=_blankCached(c,_commentLangFor(p));fc[p]=c;aR.push(...scanRoutes(p,cc));const ta=performAnalysis(p,c);pfr[p]=ta;aF.push(...ta.findings);aSrc.push(...ta.sources);aSink.push(...ta.sinks);aSan.push(...ta.sanitizers);aLogic.push(...scanLogicVulns(p,cc));aSecrets.push(...scanCredentials(p,c));aF.push(...scanStructuralVulns(p,cc));aF.push(...scanExtraStructural(p,cc));aF.push(...scanAliasedSinks(p,cc));aF.push(...scanJavaSAST(p,cc));aF.push(...scanJavaBenchExtras(p,cc));aLogic.push(...scanMiddlewareOrdering(p,cc));aLogic.push(...scanReDoS(p,cc));if(/\.(?:java|cs|kt|py|php|phtml)$/i.test(p)){try{aLogic.push(...scanRegexReDoS(p,cc));}catch(_){}}aLogic.push(...scanTodosNearSecurity(p,c));aSecrets.push(...scanEntropySecrets(p,c));const cp=scanCiphers(p,cc);aCiphersRest.push(...cp.atRest);aCiphersTransit.push(...cp.inTransit);if(/\.(graphql|gql)$/i.test(p))aF.push(...scanGraphQL(p,cc));aF.push(...scanIaC(p,cc));aF.push(...scanTerraform(p,cc));
|
|
8366
|
+
try{const c=fileContents[p];if(!c||c.length>500000){_filesSkipped++;continue;}const _avgLine=c.length/Math.max(c.split('\n').length,1);if(_avgLine>400&&c.length>10000){_filesDenseSkipped++;continue;}const cc=_blankCached(c,_commentLangFor(p));fc[p]=c;aR.push(...scanRoutes(p,cc));const ta=performAnalysis(p,c);pfr[p]=ta;aF.push(...ta.findings);aSrc.push(...ta.sources);aSink.push(...ta.sinks);aSan.push(...ta.sanitizers);aLogic.push(...scanLogicVulns(p,cc));aSecrets.push(...scanCredentials(p,c));aF.push(...scanStructuralVulns(p,cc));aF.push(...scanExtraStructural(p,cc));aF.push(...scanAliasedSinks(p,cc));aF.push(...scanJavaSAST(p,cc));aF.push(...scanJavaBenchExtras(p,cc));aLogic.push(...scanMiddlewareOrdering(p,cc));aLogic.push(...scanReDoS(p,cc));if(/\.(?:java|cs|kt|py|php|phtml)$/i.test(p)){try{aLogic.push(...scanRegexReDoS(p,cc));}catch(_){}}aLogic.push(...scanTodosNearSecurity(p,c));aSecrets.push(...scanEntropySecrets(p,c));const cp=scanCiphers(p,cc);aCiphersRest.push(...cp.atRest);aCiphersTransit.push(...cp.inTransit);if(/\.(graphql|gql)$/i.test(p))aF.push(...scanGraphQL(p,cc));aF.push(...scanIaC(p,cc));aF.push(...scanTerraform(p,cc));aF.push(...scanCloudTemplates(p,c));
|
|
8111
8367
|
aF.push(...scanLLM(p,c));
|
|
8112
8368
|
aF.push(...scanLLMOwasp(p,c));
|
|
8113
8369
|
aF.push(...scanLlmCost(p,c));
|
|
@@ -8116,6 +8372,31 @@ function _deterministicFileTimings(timings) {
|
|
|
8116
8372
|
aF.push(...scanContainer(p,cc));
|
|
8117
8373
|
aF.push(...scanInstallScripts(p,cc));
|
|
8118
8374
|
aF.push(...scanMCP(p,c));
|
|
8375
|
+
// PRD F5.2 — rug-pull: a tool whose definition changed AFTER approval.
|
|
8376
|
+
// Every scanMCP rule judges the CURRENT content, so a description that is
|
|
8377
|
+
// innocuous today and hostile tomorrow passes both scans. This compares
|
|
8378
|
+
// against a recorded baseline, which is the only way to see a change.
|
|
8379
|
+
// Wired here rather than left as a tested module: a detector with no call
|
|
8380
|
+
// site is a dark detector, which is the exact class this session keeps
|
|
8381
|
+
// finding.
|
|
8382
|
+
if (/(?:^|[\\/])\.?mcp(?:\.[a-z]+)?\.json$|(?:^|[\\/])\.mcp\.json$/i.test(p)) {
|
|
8383
|
+
try {
|
|
8384
|
+
const _cfg = JSON.parse(c);
|
|
8385
|
+
const _rp = _detectRugPull(scanRoot, _cfg, { file: p });
|
|
8386
|
+
aF.push(..._rp.findings);
|
|
8387
|
+
// Record on first sight so the NEXT scan has something to compare
|
|
8388
|
+
// against; refresh after reporting so a reviewed change is not
|
|
8389
|
+
// re-reported forever.
|
|
8390
|
+
_saveMcpBaseline(scanRoot, _fingerprintMcp(_cfg));
|
|
8391
|
+
} catch (e) {
|
|
8392
|
+
// Only a malformed config is tolerated here — scanMCP already reports
|
|
8393
|
+
// what it can from one. Anything else is a programmer error and must
|
|
8394
|
+
// not be swallowed: a bare `catch {}` around this block hid a
|
|
8395
|
+
// ReferenceError (`root` vs `scanRoot`) that silently disabled the
|
|
8396
|
+
// whole detector while every unit test still passed.
|
|
8397
|
+
if (!(e instanceof SyntaxError)) throw e;
|
|
8398
|
+
}
|
|
8399
|
+
}
|
|
8119
8400
|
aF.push(...scanClaudeSettings(p,c));
|
|
8120
8401
|
aF.push(...scanClaudeMdPromptInjection(p,c));
|
|
8121
8402
|
aF.push(...scanClaudeHookInjection(p,c));
|
|
@@ -8200,7 +8481,7 @@ function _deterministicFileTimings(timings) {
|
|
|
8200
8481
|
aF.push(...scanSSRFCloudMetadata(p,cc));
|
|
8201
8482
|
aF.push(...scanMutationXSS(p,cc));
|
|
8202
8483
|
aF.push(...scanKotlin(p,cc));
|
|
8203
|
-
aF.push(...scanRuby(p,cc));
|
|
8484
|
+
aF.push(...scanRuby(p,cc));aF.push(...scanRubyPathJoin(p,cc));
|
|
8204
8485
|
aF.push(...scanPhp(p,cc));
|
|
8205
8486
|
// Integration block: scaffolded SAST scanners. Gated by env var.
|
|
8206
8487
|
if (process.env.AGENTIC_SECURITY_NO_INTEGRATION !== '1') {
|
|
@@ -8857,13 +9138,24 @@ function _deterministicFileTimings(timings) {
|
|
|
8857
9138
|
// sanitizer would then hide a real vulnerability outright, whereas a label
|
|
8858
9139
|
// only demotes confidence here. Recall-preserving, on purpose.
|
|
8859
9140
|
const sanitizersOnPath = {};
|
|
9141
|
+
// The same shape for calls that UNDO an encoding. A sanitizer whose effect
|
|
9142
|
+
// is reversed later on the path is not a sanitizer, and the gate could not
|
|
9143
|
+
// see that before: the catalog only models sanitizers, so a decoder was
|
|
9144
|
+
// never recorded at all and `he.decode(escapeHtml(x))` read as clean.
|
|
9145
|
+
const unsanitizersOnPath = {};
|
|
8860
9146
|
for (const f of finalFindings) {
|
|
8861
9147
|
const names = f && f._sanitizersOnPath;
|
|
8862
|
-
if (
|
|
8863
|
-
|
|
8864
|
-
|
|
9148
|
+
if (Array.isArray(names) && names.length) {
|
|
9149
|
+
if (f.id) sanitizersOnPath[f.id] = names;
|
|
9150
|
+
if (f.stableId) sanitizersOnPath[f.stableId] = names;
|
|
9151
|
+
}
|
|
9152
|
+
const undo = f && f._unsanitizersOnPath;
|
|
9153
|
+
if (Array.isArray(undo) && undo.length) {
|
|
9154
|
+
if (f.id) unsanitizersOnPath[f.id] = undo;
|
|
9155
|
+
if (f.stableId) unsanitizersOnPath[f.stableId] = undo;
|
|
9156
|
+
}
|
|
8865
9157
|
}
|
|
8866
|
-
_runAnnotator("applySanitizerGate", () => { applySanitizerGate(finalFindings, { sanitizersOnPath }); });
|
|
9158
|
+
_runAnnotator("applySanitizerGate", () => { applySanitizerGate(finalFindings, { sanitizersOnPath, unsanitizersOnPath }); });
|
|
8867
9159
|
_runAnnotator("annotateProofGate", () => { annotateProofGate(finalFindings); });
|
|
8868
9160
|
}
|
|
8869
9161
|
// Addition #1 — default falsification pass. Actively tries to DISPROVE each
|
|
@@ -9557,7 +9849,13 @@ function _deterministicFileTimings(timings) {
|
|
|
9557
9849
|
// Addition #3 — root-cause sweep: from confirmed findings, find sibling instances
|
|
9558
9850
|
// detectors missed, with total-count accounting. Confirmed-only (cheap by default).
|
|
9559
9851
|
let _rootCauseSweep = null; try { _rootCauseSweep = sweepRootCauses(finalFindings, fc); } catch { _rootCauseSweep = null; }
|
|
9560
|
-
|
|
9852
|
+
// PRD F7.2: publish what CANNOT be proven alongside what can. A proof RATE
|
|
9853
|
+
// computed over the provable subset makes a narrow subset look like strength;
|
|
9854
|
+
// the three-bucket split (provable / declined-on-purpose / not-yet-classified)
|
|
9855
|
+
// is the honest shape. Measured on the CVE corpus: 19% / 13% / 68%.
|
|
9856
|
+
let _proofCoverage = null;
|
|
9857
|
+
try { _proofCoverage = proofCoverage([...finalFindings, ...aLogic]); } catch { _proofCoverage = null; }
|
|
9858
|
+
return{entrypointInventory:_entrypointInventory,rootCauseSweep:_rootCauseSweep,proofCoverage:_proofCoverage,kevCatalog:kevCatalogMeta(),routes:dd(aR,r=>`${r.method}:${r.path}:${r.file}:${r.line}`),findings:finalFindings,sources:aSrc,sinks:aSink,sanitizers:aSan,filesScanned:files.length,crossFileCount:cf.length,logicVulns:aLogic,supplyChain,components:annotatedComponents,secrets:aSecrets,ciphers:{atRest:aCiphersRest,inTransit:aCiphersTransit},pfr,fc,suppressions:_getSuppressions(),_v3,_scanMeta,_engineErrors:{cppDataflowParseErrors:_cppDataflowParseErrors.value},annotatorErrors:_annotatorErrors,executionProof:_executionProofSummary,logicClaims:_logicClaims,vulnHistory:_vulnHistory,threatModel:_threatModel,privacyFramework:_privacyFramework,sbomDiff:_sbomDiff,complianceReport:_complianceReport,exploitBundles:_exploitBundles,pqcPlan:_pqcPlan,licenseGraph:_licenseGraph,attributions:_attributions,attackTaxonomy:_taxonomySummary};}
|
|
9561
9859
|
|
|
9562
9860
|
// Post-aggregation classification: every source becomes "unsafe"|"safe"; every sink becomes "confirmed"|"safe".
|
|
9563
9861
|
// Orphans (no finding linkage) are bucketed by file-local heuristic so the UI shows binary states only.
|
|
@@ -9767,6 +10065,30 @@ const CREDENTIAL_PATTERNS=[
|
|
|
9767
10065
|
// Database / Infrastructure
|
|
9768
10066
|
// ctx gate: JDBC URLs in docs/test configs without credentials are not findings; require @ or password= evidence
|
|
9769
10067
|
{n:"Database Connection String",r:"jdbc:[a-z:]+://[A-Za-z0-9\\.\\-_:;=/@?,&]+",s:"h",ctx:/@|password=|passwd=|pwd=/i},
|
|
10068
|
+
// PRD F4.1. bench/secrets-precision measured format coverage at 83% and every
|
|
10069
|
+
// one of these five was a genuine absence, not a tuning problem. Four are
|
|
10070
|
+
// among the most common real leaks there are — a database URI with the
|
|
10071
|
+
// password inline is what a connection string looks like when someone pastes
|
|
10072
|
+
// one into a config file.
|
|
10073
|
+
//
|
|
10074
|
+
// `jdbc:` was the ONLY database URI shape covered. `postgres://` and
|
|
10075
|
+
// `mongodb+srv://` are far more common in the ecosystems this tool is aimed
|
|
10076
|
+
// at, and the generic "Password in URL" pattern could not reach them: it is
|
|
10077
|
+
// gated behind CRED_PREFILTER, which had no token for either scheme.
|
|
10078
|
+
{n:"PostgreSQL Connection URI",r:"postgres(?:ql)?://[^\\s:@/]{1,64}:[^\\s:@/]{6,64}@[^\\s/\"']{1,128}",s:"h",urlCreds:true},
|
|
10079
|
+
{n:"MongoDB Connection URI",r:"mongodb(?:\\+srv)?://[^\\s:@/]{1,64}:[^\\s:@/]{6,64}@[^\\s/\"']{1,128}",s:"h",urlCreds:true},
|
|
10080
|
+
{n:"Azure Storage Account Key",r:"AccountKey=[A-Za-z0-9+/]{86}==",s:"c"},
|
|
10081
|
+
{n:"GitLab Personal Access Token",r:"glpat-[0-9A-Za-z_-]{20}",s:"c"},
|
|
10082
|
+
{n:"DigitalOcean Personal Access Token",r:"dop_v1_[a-f0-9]{64}",s:"c"},
|
|
10083
|
+
{n:"Supabase Service Key",r:"sbp_[a-f0-9]{40}",s:"c"},
|
|
10084
|
+
{n:"HubSpot Private App Token",r:"pat-(?:na|eu)[0-9]-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}",s:"c"},
|
|
10085
|
+
// NOT added, deliberately: Datadog, Vercel and Algolia keys are a bare run of
|
|
10086
|
+
// hex or alphanumerics with no prefix. bench/secrets-precision reports them as
|
|
10087
|
+
// misses and they should stay reported. A pattern for "32 hex characters"
|
|
10088
|
+
// would fire on every content digest, Cargo checksum, test vector and build
|
|
10089
|
+
// hash in the negative set — trading five detections for thousands of false
|
|
10090
|
+
// positives, in the feature most prone to alert fatigue. Closing this needs
|
|
10091
|
+
// variable-name context, not another regex.
|
|
9770
10092
|
// Downgraded to medium; scanner also skips localhost/example hosts (see scanCredentials)
|
|
9771
10093
|
{n:"Password in URL",r:"[a-zA-Z]{3,10}://[^/\\s:@]{3,20}:[^/\\s:@]{3,20}@.{1,100}[\"'\\s]",s:"m"},
|
|
9772
10094
|
{n:"WordPress Secret Key",r:"define(.{0,20})?(DB_PASSWORD|AUTH_KEY|SECURE_AUTH_KEY|LOGGED_IN_KEY|AUTH_SALT|NONCE_KEY).{0,20}['\"].{10,120}['\"]",s:"h"},
|
|
@@ -9778,7 +10100,7 @@ const CREDENTIAL_PATTERNS=[
|
|
|
9778
10100
|
// ctx gate: only report when the line contains a storage/assignment keyword, filters standalone examples in comments
|
|
9779
10101
|
{n:"Exposed JWT Token",r:"eyJ[a-zA-Z0-9]{10,}\\.eyJ[a-zA-Z0-9]{10,}\\.[a-zA-Z0-9_\\-]{10,}",s:"m",ctx:/token|jwt|auth|bearer|secret|key|credential|sign|=|:/i},
|
|
9780
10102
|
];
|
|
9781
|
-
const CRED_PREFILTER=/AKIA|AGPA|AIDA|AROA|AIPA|ANPA|ANVA|ASIA|da2-[a-z0-9]{10}|amzn\.mws|AIza|ya29\.[0-9A-Za-z]{15}|googleusercontent|[Hh][Ee][Rr][Oo][Kk][Uu]|dt0[A-Za-z][0-9]{2}\.|ghp_|gho_|ghu_|ghs_|ghr_|sk_live_|sk_test_|rk_live_|access_token\$production|sq0atp|sq0csp|xox[baprs]-[0-9a-zA-Z]{8}|hooks\.slack\.com|discord(?:app)?\.com\/api\/webhooks|outlook\.office\.com\/webhook|[0-9]{8,10}:AA[0-9A-Za-z]|AAAA[a-zA-Z0-9_-]{7}:|twilio|SG\.[a-zA-Z0-9_-]{15}|mailchimp|key-[0-9a-zA-Z]{20}|shpat_|shpss_|shpca_|shppa_|-----BEGIN .*(PRIVATE|PGP)|NRAA-|NRII-|NRIQ-|NRRA-|EAACEdEose0cBA|pypi-AgEIcH|hooks\.zapier\.com|jdbc:|cloudinary:\/\/|R_[0-9a-f]{20}|eyJ[a-zA-Z0-9]{10,}\.eyJ
|
|
10103
|
+
const CRED_PREFILTER=/AKIA|AGPA|AIDA|AROA|AIPA|ANPA|ANVA|ASIA|da2-[a-z0-9]{10}|amzn\.mws|AIza|ya29\.[0-9A-Za-z]{15}|googleusercontent|[Hh][Ee][Rr][Oo][Kk][Uu]|dt0[A-Za-z][0-9]{2}\.|ghp_|gho_|ghu_|ghs_|ghr_|sk_live_|sk_test_|rk_live_|access_token\$production|sq0atp|sq0csp|xox[baprs]-[0-9a-zA-Z]{8}|hooks\.slack\.com|discord(?:app)?\.com\/api\/webhooks|outlook\.office\.com\/webhook|[0-9]{8,10}:AA[0-9A-Za-z]|AAAA[a-zA-Z0-9_-]{7}:|twilio|SG\.[a-zA-Z0-9_-]{15}|mailchimp|key-[0-9a-zA-Z]{20}|shpat_|shpss_|shpca_|shppa_|-----BEGIN .*(PRIVATE|PGP)|NRAA-|NRII-|NRIQ-|NRRA-|EAACEdEose0cBA|pypi-AgEIcH|hooks\.zapier\.com|jdbc:|cloudinary:\/\/|R_[0-9a-f]{20}|eyJ[a-zA-Z0-9]{10,}\.eyJ|postgres(?:ql)?:\/\/|mongodb(?:\+srv)?:\/\/|AccountKey=|glpat-|dop_v1_|sbp_[a-f0-9]{10}|pat-(?:na|eu)[0-9]-|[a-z][a-z0-9+.-]{2,15}:\/\/[^\s:@\/]{3,64}:[^\s:@\/]{3,64}@/i;
|
|
9782
10104
|
const SECRET_IMPACT_MAP={
|
|
9783
10105
|
"AWS Access Key ID":"Provides programmatic access to AWS resources. With the paired secret key, an attacker can enumerate S3 buckets, exfiltrate databases, spin up EC2 instances for cryptomining, or pivot to any service the role permits. If the key belongs to an admin role, this is full cloud account takeover.",
|
|
9784
10106
|
"AWS AppSync GraphQL Key":"Allows unauthenticated queries and mutations against your AppSync GraphQL API. Attackers can read application data, trigger mutations to corrupt records, or enumerate the schema to map further attack surface.",
|
|
@@ -10000,7 +10322,7 @@ export {
|
|
|
10000
10322
|
classifyOrphans, classifyField, classifyEndpoint, shouldScan,
|
|
10001
10323
|
_isFalsePositiveCredential, _detectSafeSinkShape,
|
|
10002
10324
|
_loadCustomRules, _isCustomSuppressed, _isPathIgnored,
|
|
10003
|
-
scanIaC, IAC_PATTERNS, _isIaCFile,
|
|
10325
|
+
scanIaC, IAC_PATTERNS, _isIaCFile, isCloudFormationTemplate,
|
|
10004
10326
|
payloadsForFinding, buildProofObligation,
|
|
10005
10327
|
DATA_CLASSES, SOURCE_PATTERNS, SINK_PATTERNS, SANITIZER_PATTERNS,
|
|
10006
10328
|
ROUTE_PATTERNS, AUTH_PATTERNS, IGNORE_DIRS, CODE_EXTS,
|