@clear-capabilities/agentic-security-scanner 0.139.1 → 0.141.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 +221 -0
- package/bin/agentic-security.js +40 -11
- 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 +16 -5
- package/src/dataflow/catalog.js +61 -0
- package/src/engine.js +281 -23
- 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 +137 -21
- 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 +42 -5
- 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/integrity.js +59 -8
- package/src/posture/mcp-rug-pull.js +144 -0
- package/src/posture/poc-generator.js +17 -1
- 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 +50 -7
- 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 +337 -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
|
@@ -38,12 +38,14 @@ const MARKER = 'PROVEN';
|
|
|
38
38
|
// PoC observes the acceptance and records it. Both are execution-based — code
|
|
39
39
|
// ran and behaved insecurely — but they are proven by different evidence, and
|
|
40
40
|
// conflating them is how a template ends up asserting something it never saw.
|
|
41
|
-
const SUPPORTED = new Set([
|
|
41
|
+
export const SUPPORTED = new Set([
|
|
42
42
|
'command-injection',
|
|
43
43
|
'code-injection',
|
|
44
44
|
'webhook-missing-signature-verification',
|
|
45
45
|
'sql-injection',
|
|
46
46
|
'path-traversal',
|
|
47
|
+
'ldap-injection',
|
|
48
|
+
'xxe',
|
|
47
49
|
]);
|
|
48
50
|
|
|
49
51
|
// Classes proven by observing the HANDLER's behaviour rather than a payload
|
|
@@ -53,6 +55,8 @@ const BEHAVIOURAL = new Map([
|
|
|
53
55
|
['webhook-missing-signature-verification', (f, c) => _webhookPoc(f, c)],
|
|
54
56
|
['sql-injection', (f, c) => _sqlInjectionPoc(f, c)],
|
|
55
57
|
['path-traversal', (f, c) => _pathTraversalPoc(f, c)],
|
|
58
|
+
['ldap-injection', (f, c) => _ldapInjectionPoc(f, c)],
|
|
59
|
+
['xxe', (f, c) => _xxePoc(f, c)],
|
|
56
60
|
]);
|
|
57
61
|
|
|
58
62
|
// Classes deliberately NOT here, with the reason, so the gap is a decision
|
|
@@ -491,6 +495,218 @@ function _sqlInjectionPoc(finding, fileContent) {
|
|
|
491
495
|
const TRAVERSAL_SENTINEL = 'PROVEN_TRAVERSAL_SENTINEL_CONTENT';
|
|
492
496
|
const READ_SINK = /\b(?:readFile|readFileSync|sendFile|readFileAsync)\s*\(/;
|
|
493
497
|
|
|
498
|
+
|
|
499
|
+
// ── LDAP injection (CWE-90) ────────────────────────────────────────────────
|
|
500
|
+
//
|
|
501
|
+
// Added because `ldap-injection` was the largest UNCLASSIFIED family in the
|
|
502
|
+
// published proof-coverage breakdown (10 of 280 corpus findings) and it has the
|
|
503
|
+
// same property that makes SQL provable: a decidable boundary where the
|
|
504
|
+
// question is settled without a running directory server.
|
|
505
|
+
//
|
|
506
|
+
// For SQL the question is "did the payload arrive as query TEXT or as a bound
|
|
507
|
+
// parameter". For LDAP it is "did the payload's filter METACHARACTERS survive".
|
|
508
|
+
// A correctly escaped value still contains the sentinel — `ldap_escape`,
|
|
509
|
+
// `EqualityFilter` and friends escape `(`, `)`, `*` and `\` to `\28`, `\29`,
|
|
510
|
+
// `\2a`, `\5c` — so matching the sentinel alone would call a FIXED handler
|
|
511
|
+
// vulnerable. The proof requires the sentinel AND an unescaped metacharacter in
|
|
512
|
+
// the same filter string.
|
|
513
|
+
const LDAP_SENTINEL = 'PROVEN_LDAPI';
|
|
514
|
+
// RELATIVE, like SQL_LOG. An absolute path (/tmp/...) is denied by the
|
|
515
|
+
// confinement backend, so the recorder silently wrote nothing and the proof
|
|
516
|
+
// came back proof-failed on a genuinely vulnerable handler — the sandbox
|
|
517
|
+
// working exactly as intended, and a reminder that a PoC must live entirely
|
|
518
|
+
// inside the sandbox root.
|
|
519
|
+
const LDAP_LOG = 'ldap-calls.jsonl';
|
|
520
|
+
const LDAP_DRIVERS = ['ldapjs', 'ldap-authentication', 'activedirectory', 'activedirectory2', 'ldapts'];
|
|
521
|
+
const LDAP_DRIVER_RES = LDAP_DRIVERS.map((d) => [
|
|
522
|
+
d, new RegExp(`require\\(\\s*['"\`]${d}['"\`]|from\\s*['"\`]${d}['"\`]`),
|
|
523
|
+
]);
|
|
524
|
+
|
|
525
|
+
const LDAP_STUB = [
|
|
526
|
+
"const fs = require('fs');",
|
|
527
|
+
'function record(args) {',
|
|
528
|
+
' const filters = [];',
|
|
529
|
+
' for (const a of args) {',
|
|
530
|
+
" if (typeof a === 'string') filters.push(a);",
|
|
531
|
+
" else if (a && typeof a === 'object') {",
|
|
532
|
+
" if (typeof a.filter === 'string') filters.push(a.filter);",
|
|
533
|
+
" if (a.filter && typeof a.filter === 'object' && typeof a.filter.toString === 'function') filters.push(String(a.filter));",
|
|
534
|
+
' }',
|
|
535
|
+
' }',
|
|
536
|
+
' if (filters.length) {',
|
|
537
|
+
` try { fs.appendFileSync(${JSON.stringify(LDAP_LOG)}, JSON.stringify({ filters }) + '\\n'); } catch {}`,
|
|
538
|
+
' }',
|
|
539
|
+
'}',
|
|
540
|
+
'const mk = () => new Proxy(function () {}, {',
|
|
541
|
+
" get(t, p) { if (typeof p === 'symbol' || p === 'then' || p === 'inspect') return undefined; return mk(); },",
|
|
542
|
+
' apply(t, self, args) {',
|
|
543
|
+
' record(args);',
|
|
544
|
+
" for (const a of args) if (typeof a === 'function') { try { a(null, { on: () => {} }); } catch {} }",
|
|
545
|
+
' return mk();',
|
|
546
|
+
' },',
|
|
547
|
+
'});',
|
|
548
|
+
'module.exports = mk();',
|
|
549
|
+
].join('\n');
|
|
550
|
+
|
|
551
|
+
function _ldapInjectionPoc(finding, fileContent) {
|
|
552
|
+
const driver = (LDAP_DRIVER_RES.find(([, re]) => re.test(fileContent)) || [])[0];
|
|
553
|
+
if (!driver) {
|
|
554
|
+
return {
|
|
555
|
+
ok: false,
|
|
556
|
+
reason: `no recognised LDAP client is required by this file, so there is no boundary to observe the filter at (looked for: ${LDAP_DRIVERS.join(', ')})`,
|
|
557
|
+
};
|
|
558
|
+
}
|
|
559
|
+
const found = _findHandler(fileContent);
|
|
560
|
+
if (!found) return NO_HANDLER;
|
|
561
|
+
const { call, reqIdent } = found;
|
|
562
|
+
|
|
563
|
+
const src = _requestSource(fileContent, reqIdent, finding.line);
|
|
564
|
+
if (!src) {
|
|
565
|
+
return { ok: false, reason: `the handler does not read query/body/params off '${reqIdent}', so the injection point is unknown` };
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
const { base, importLine, invoke, handlerLabel } = _binding(finding, call);
|
|
569
|
+
// Filter metacharacters plus the sentinel. An escaping handler keeps the
|
|
570
|
+
// sentinel and neutralises the parens/star, which is exactly the difference
|
|
571
|
+
// the decision below reads.
|
|
572
|
+
const payload = `*)(uid=*))(|(uid=*${LDAP_SENTINEL}`;
|
|
573
|
+
// The distinctive raw tail of that payload. Present verbatim only if no
|
|
574
|
+
// escaping happened between the request and the client.
|
|
575
|
+
const RAW_TAIL = `)(|(uid=*${LDAP_SENTINEL}`;
|
|
576
|
+
|
|
577
|
+
const code = [
|
|
578
|
+
`// Auto-generated proof for ${finding.file}.`,
|
|
579
|
+
`// Stubs the '${driver}' client with a recorder and calls the handler with a`,
|
|
580
|
+
'// payload carrying LDAP filter metacharacters. The marker is written only if',
|
|
581
|
+
'// those metacharacters reached the filter UNESCAPED — an escaped value still',
|
|
582
|
+
'// carries the sentinel, so the sentinel alone proves nothing.',
|
|
583
|
+
importLine,
|
|
584
|
+
"import fs from 'node:fs';",
|
|
585
|
+
'await new Promise((resolve) => {',
|
|
586
|
+
' let timer = null;',
|
|
587
|
+
' const done = () => { clearTimeout(timer); resolve(); };',
|
|
588
|
+
' const res = {',
|
|
589
|
+
' send: done, json: done, end: done,',
|
|
590
|
+
' status: () => ({ send: done, json: done, end: done }),',
|
|
591
|
+
' };',
|
|
592
|
+
` const req = { ${src.prop}: ${JSON.stringify({ [src.key]: payload })} };`,
|
|
593
|
+
' timer = setTimeout(resolve, 3000);',
|
|
594
|
+
` try { ${invoke}(req, res); } catch { done(); }`,
|
|
595
|
+
'});',
|
|
596
|
+
'',
|
|
597
|
+
'// The whole decision, stated once: did the PAYLOAD\'s OWN metacharacters',
|
|
598
|
+
'// survive verbatim? Looking for any raw paren near the sentinel does not',
|
|
599
|
+
'// work — the application\'s filter template (\'(uid=\' + v + \')\') always',
|
|
600
|
+
'// contributes raw parens of its own, so that test called an ESCAPING',
|
|
601
|
+
'// handler vulnerable. Escaping rewrites the payload tail to \\2a\\29\\28…,',
|
|
602
|
+
'// so its verbatim presence is the bug and its absence is the fix working.',
|
|
603
|
+
'let proven = false;',
|
|
604
|
+
'try {',
|
|
605
|
+
` for (const line of fs.readFileSync(${JSON.stringify(LDAP_LOG)}, 'utf8').split('\\n')) {`,
|
|
606
|
+
' if (!line.trim()) continue;',
|
|
607
|
+
' const rec = JSON.parse(line);',
|
|
608
|
+
` for (const f of rec.filters) if (f.includes(${JSON.stringify(RAW_TAIL)})) proven = true;`,
|
|
609
|
+
' }',
|
|
610
|
+
'} catch {}',
|
|
611
|
+
`if (proven) fs.writeFileSync('${MARKER}', 'x');`,
|
|
612
|
+
].join('\n');
|
|
613
|
+
|
|
614
|
+
return {
|
|
615
|
+
ok: true,
|
|
616
|
+
poc: {
|
|
617
|
+
lang: 'js', kind: 'in-process', family: finding.family, cwe: finding.cwe || null,
|
|
618
|
+
marker: MARKER, paramKey: src.key, paramSource: src.prop, handler: handlerLabel,
|
|
619
|
+
driver,
|
|
620
|
+
observes: 'the request payload reached the LDAP client with its filter metacharacters unescaped',
|
|
621
|
+
requires: [base],
|
|
622
|
+
extraFiles: { [`node_modules/${driver}/index.js`]: LDAP_STUB },
|
|
623
|
+
code,
|
|
624
|
+
},
|
|
625
|
+
};
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
// ── XXE (CWE-611) ──────────────────────────────────────────────────────────
|
|
630
|
+
//
|
|
631
|
+
// The third-largest unclassified family in the published proof-coverage
|
|
632
|
+
// breakdown. It is provable in-process for a reason the SSRF class is NOT: the
|
|
633
|
+
// external entity can point at a LOCAL file the harness plants inside the
|
|
634
|
+
// sandbox, so the proof needs no network and a failed fetch can never be
|
|
635
|
+
// confinement talking.
|
|
636
|
+
//
|
|
637
|
+
// The decision is whether the parser RESOLVED the entity. The sentinel content
|
|
638
|
+
// lives only in a file the XML never contains, so its presence in the parsed
|
|
639
|
+
// output means the parser went and read it — which is the vulnerability. A
|
|
640
|
+
// parser with entity expansion off returns the entity unexpanded (or throws),
|
|
641
|
+
// and neither writes the marker.
|
|
642
|
+
const XXE_SENTINEL = 'PROVEN_XXE_ENTITY_RESOLVED';
|
|
643
|
+
const XXE_SENTINEL_FILE = 'xxe-sentinel.txt';
|
|
644
|
+
const XXE_PARSERS = ['libxmljs', 'libxmljs2', '@xmldom/xmldom', 'xmldom', 'node-expat'];
|
|
645
|
+
const XXE_PARSER_RES = XXE_PARSERS.map((d) => [
|
|
646
|
+
d, new RegExp(`require\\(\\s*['"\`]${d.replace('/', '\\/')}['"\`]|from\\s*['"\`]${d.replace('/', '\\/')}['"\`]`),
|
|
647
|
+
]);
|
|
648
|
+
|
|
649
|
+
function _xxePoc(finding, fileContent) {
|
|
650
|
+
const parser = (XXE_PARSER_RES.find(([, re]) => re.test(fileContent)) || [])[0];
|
|
651
|
+
if (!parser) {
|
|
652
|
+
return {
|
|
653
|
+
ok: false,
|
|
654
|
+
reason: `no recognised XML parser is required by this file, so there is nothing to observe entity resolution at (looked for: ${XXE_PARSERS.join(', ')})`,
|
|
655
|
+
};
|
|
656
|
+
}
|
|
657
|
+
const found = _findHandler(fileContent);
|
|
658
|
+
if (!found) return NO_HANDLER;
|
|
659
|
+
const { call, reqIdent } = found;
|
|
660
|
+
|
|
661
|
+
const src = _requestSource(fileContent, reqIdent, finding.line);
|
|
662
|
+
if (!src) {
|
|
663
|
+
return { ok: false, reason: `the handler does not read query/body/params off '${reqIdent}', so the XML entry point is unknown` };
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
const { base, importLine, invoke, handlerLabel } = _binding(finding, call);
|
|
667
|
+
const xml = `<?xml version="1.0"?><!DOCTYPE r [<!ENTITY x SYSTEM "file://./${XXE_SENTINEL_FILE}">]><r>&x;</r>`;
|
|
668
|
+
|
|
669
|
+
const code = [
|
|
670
|
+
`// Auto-generated proof for ${finding.file}.`,
|
|
671
|
+
'// Plants a sentinel file, hands the handler XML whose external entity points',
|
|
672
|
+
'// at it, and writes the marker only if the SENTINEL CONTENT comes back —',
|
|
673
|
+
'// which can only happen if the parser resolved the entity and read the file.',
|
|
674
|
+
'// A parser with entity expansion disabled returns it unexpanded or throws.',
|
|
675
|
+
importLine,
|
|
676
|
+
"import fs from 'node:fs';",
|
|
677
|
+
`fs.writeFileSync(${JSON.stringify(XXE_SENTINEL_FILE)}, ${JSON.stringify(XXE_SENTINEL)});`,
|
|
678
|
+
'let seen = "";',
|
|
679
|
+
'await new Promise((resolve) => {',
|
|
680
|
+
' let timer = null;',
|
|
681
|
+
' const capture = (v) => { try { seen += typeof v === "string" ? v : JSON.stringify(v); } catch {} clearTimeout(timer); resolve(); };',
|
|
682
|
+
' const res = {',
|
|
683
|
+
' send: capture, json: capture, end: capture,',
|
|
684
|
+
' status: () => ({ send: capture, json: capture, end: capture }),',
|
|
685
|
+
' };',
|
|
686
|
+
` const req = { ${src.prop}: ${JSON.stringify({ [src.key]: xml })} };`,
|
|
687
|
+
' timer = setTimeout(resolve, 3000);',
|
|
688
|
+
` try { const r = ${invoke}(req, res); if (r && typeof r.then === "function") r.then(capture, () => resolve()); } catch { resolve(); }`,
|
|
689
|
+
'});',
|
|
690
|
+
'',
|
|
691
|
+
'// The whole decision, stated once: the sentinel STRING is only reachable by',
|
|
692
|
+
'// reading the planted file. Matching the entity name or the XML itself would',
|
|
693
|
+
'// be satisfied by a parser that never expanded anything.',
|
|
694
|
+
`if (seen.includes(${JSON.stringify(XXE_SENTINEL)})) fs.writeFileSync('${MARKER}', 'x');`,
|
|
695
|
+
].join('\n');
|
|
696
|
+
|
|
697
|
+
return {
|
|
698
|
+
ok: true,
|
|
699
|
+
poc: {
|
|
700
|
+
lang: 'js', kind: 'in-process', family: finding.family, cwe: finding.cwe || null,
|
|
701
|
+
marker: MARKER, paramKey: src.key, paramSource: src.prop, handler: handlerLabel,
|
|
702
|
+
driver: parser,
|
|
703
|
+
observes: 'the XML parser resolved an external entity and returned the contents of a local file',
|
|
704
|
+
requires: [base],
|
|
705
|
+
code,
|
|
706
|
+
},
|
|
707
|
+
};
|
|
708
|
+
}
|
|
709
|
+
|
|
494
710
|
function _pathTraversalPoc(finding, fileContent) {
|
|
495
711
|
const found = _findHandler(fileContent);
|
|
496
712
|
if (!found) return NO_HANDLER;
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// PRD F7.2 — publish what CANNOT be proven, and how much of the finding set that is.
|
|
2
|
+
//
|
|
3
|
+
// The PoC generator declines whole vulnerability classes it cannot honestly
|
|
4
|
+
// prove. Those reasons were documented in poc-inprocess.js's header and nowhere
|
|
5
|
+
// else, so the only number a reader ever saw was the proof RATE — computed over
|
|
6
|
+
// the findings that happened to be provable. A high rate on a small provable
|
|
7
|
+
// subset reads as strength and is nearly meaningless.
|
|
8
|
+
//
|
|
9
|
+
// Publishing which classes are structurally unprovable, and what share of real
|
|
10
|
+
// findings they represent, is more credible than a high proof rate. It is also
|
|
11
|
+
// the harder number to publish, which is rather the point.
|
|
12
|
+
//
|
|
13
|
+
// THE THREE BUCKETS, and why "unclassified" is separate from "indeterminate":
|
|
14
|
+
//
|
|
15
|
+
// provable — a proof class exists and a PoC can be attempted.
|
|
16
|
+
// indeterminate — the class is DECLINED ON PURPOSE, with a stated reason.
|
|
17
|
+
// Not a gap in effort; a limit of what a single-shot harness
|
|
18
|
+
// can honestly assert.
|
|
19
|
+
// unclassified — no proof class covers it and no decision has been recorded.
|
|
20
|
+
// This is the genuine backlog, and it is kept apart so it can
|
|
21
|
+
// never hide inside the principled exclusions.
|
|
22
|
+
// out-of-scope — the in-process harness is JAVASCRIPT-ONLY. A Python, Java,
|
|
23
|
+
// C#, PHP, Kotlin, Go or Ruby finding cannot be proven by it
|
|
24
|
+
// at any effort, so it is not backlog — it is the CEILING.
|
|
25
|
+
//
|
|
26
|
+
// That fourth bucket is the "stated ceiling" Feature 7's exit gate asks for, and
|
|
27
|
+
// omitting it made the headline number mean two different things at once.
|
|
28
|
+
// Measured on the CVE corpus: 280 findings, of which only 76 are JS/TS. Proof
|
|
29
|
+
// coverage is 26% of ALL findings and 96% of the ones the harness can reach.
|
|
30
|
+
// Reporting the first alone understates the harness; reporting the second alone
|
|
31
|
+
// overstates the product. Both are published, with the denominator on each.
|
|
32
|
+
//
|
|
33
|
+
// Folding `unclassified` into `indeterminate` would let "we haven't looked at
|
|
34
|
+
// this" borrow the credibility of "we looked and it can't be done".
|
|
35
|
+
import { SUPPORTED } from './poc-inprocess.js';
|
|
36
|
+
|
|
37
|
+
// Declined on purpose. Reasons are the ones recorded in poc-inprocess.js — kept
|
|
38
|
+
// as DATA here so a report can print them, rather than living only in a comment
|
|
39
|
+
// nobody downstream can read.
|
|
40
|
+
export const INDETERMINATE_BY_CLASS = Object.freeze({
|
|
41
|
+
'idor': 'proving it means showing user A read user B\'s record, which needs two authenticated identities and a populated data store. A single-shot harness would invent both, and a PoC built on invented state proves something about the invention.',
|
|
42
|
+
'broken-access-control': 'same as idor — requires two identities and real state.',
|
|
43
|
+
'broken-authz': 'same as idor — requires two identities and real state.',
|
|
44
|
+
'ssrf': 'the proof is that the server fetched an attacker-named host. The sandbox denies egress by design, so a failed fetch is confinement talking, not the finding.',
|
|
45
|
+
'xss': 'needs a browser to say whether the payload executed; a marker file cannot observe a DOM.',
|
|
46
|
+
'mutation-xss': 'needs a DOM and a parser round-trip — same limit as xss.',
|
|
47
|
+
'open-redirect': 'the effect is a Location header a browser would follow; nothing in a marker-file harness observes the follow.',
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// The in-process harness only ever loads JavaScript (see poc-inprocess.js's
|
|
51
|
+
// JS_EXT and its "ES-module source" refusal). Anything else is unreachable by
|
|
52
|
+
// construction.
|
|
53
|
+
const HARNESS_LANGS = /\.(?:js|cjs|mjs|jsx|ts|tsx)$/i;
|
|
54
|
+
|
|
55
|
+
// Internal: `bucketOf` is the public surface for this question. Not exported —
|
|
56
|
+
// the dead-export guard is right that a second entry point with no caller is
|
|
57
|
+
// just surface area.
|
|
58
|
+
function outOfHarnessScope(finding) {
|
|
59
|
+
const file = finding && typeof finding.file === 'string' ? finding.file : '';
|
|
60
|
+
if (!file) return false; // unknown file — do not claim a ceiling
|
|
61
|
+
return !HARNESS_LANGS.test(file);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Bucket a finding: 'provable' | 'indeterminate' | 'unclassified' | 'out-of-scope'. */
|
|
65
|
+
export function bucketOf(finding) {
|
|
66
|
+
// Language first: a Python SQL-injection finding is not "provable", however
|
|
67
|
+
// good the SQL proof class is. Ordering this after the class check would
|
|
68
|
+
// report a ceiling case as a capability.
|
|
69
|
+
if (outOfHarnessScope(finding)) return 'out-of-scope';
|
|
70
|
+
const fam = finding && typeof finding.family === 'string' ? finding.family : '';
|
|
71
|
+
if (!fam) return 'unclassified';
|
|
72
|
+
// Families are emitted as `<base>-<rule-slug>` in places, so match the base
|
|
73
|
+
// too — the same resolution problem the compliance evaluator hit.
|
|
74
|
+
const base = fam.split('-').slice(0, 2).join('-');
|
|
75
|
+
for (const key of SUPPORTED) {
|
|
76
|
+
if (fam === key || fam.startsWith(`${key}-`)) return 'provable';
|
|
77
|
+
}
|
|
78
|
+
for (const key of Object.keys(INDETERMINATE_BY_CLASS)) {
|
|
79
|
+
if (fam === key || fam.startsWith(`${key}-`) || base === key) return 'indeterminate';
|
|
80
|
+
}
|
|
81
|
+
return 'unclassified';
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Proof coverage over a finding set.
|
|
86
|
+
*
|
|
87
|
+
* Every share carries {n, d} — a percentage without its denominator is exactly
|
|
88
|
+
* the shape of claim this module exists to replace.
|
|
89
|
+
*/
|
|
90
|
+
export function proofCoverage(findings) {
|
|
91
|
+
const list = Array.isArray(findings) ? findings.filter(Boolean) : [];
|
|
92
|
+
const d = list.length;
|
|
93
|
+
const buckets = { provable: [], indeterminate: [], unclassified: [], 'out-of-scope': [] };
|
|
94
|
+
for (const f of list) buckets[bucketOf(f)].push(f);
|
|
95
|
+
|
|
96
|
+
const byClass = {};
|
|
97
|
+
for (const f of buckets.indeterminate) {
|
|
98
|
+
const fam = f.family || '(none)';
|
|
99
|
+
const key = Object.keys(INDETERMINATE_BY_CLASS)
|
|
100
|
+
.find(k => fam === k || fam.startsWith(`${k}-`) || fam.split('-').slice(0, 2).join('-') === k) || fam;
|
|
101
|
+
if (!byClass[key]) byClass[key] = { n: 0, reason: INDETERMINATE_BY_CLASS[key] || 'declined' };
|
|
102
|
+
byClass[key].n += 1;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const unclassifiedFamilies = {};
|
|
106
|
+
for (const f of buckets.unclassified) {
|
|
107
|
+
const fam = f.family || '(no family)';
|
|
108
|
+
unclassifiedFamilies[fam] = (unclassifiedFamilies[fam] || 0) + 1;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const reachable = d - buckets['out-of-scope'].length;
|
|
112
|
+
const outLangs = {};
|
|
113
|
+
for (const f of buckets['out-of-scope']) {
|
|
114
|
+
const ext = (String(f.file || '').match(/\.[a-z0-9]+$/i) || ['(none)'])[0].toLowerCase();
|
|
115
|
+
outLangs[ext] = (outLangs[ext] || 0) + 1;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
total: d,
|
|
120
|
+
// Reported BOTH ways on purpose. `provable.d` is every finding; `ofReachable`
|
|
121
|
+
// is the share of what the harness can actually load. Publishing only one of
|
|
122
|
+
// them is how a number ends up meaning whatever the reader assumes.
|
|
123
|
+
provable: { n: buckets.provable.length, d, ofReachable: { n: buckets.provable.length, d: reachable } },
|
|
124
|
+
ceiling: {
|
|
125
|
+
reachable: { n: reachable, d },
|
|
126
|
+
outOfScope: { n: buckets['out-of-scope'].length, d, byExtension: outLangs },
|
|
127
|
+
reason: 'the in-process proof harness only loads JavaScript; findings in other languages cannot be execution-proven by it at any effort',
|
|
128
|
+
},
|
|
129
|
+
indeterminate: { n: buckets.indeterminate.length, d, byClass },
|
|
130
|
+
unclassified: { n: buckets.unclassified.length, d, families: unclassifiedFamilies },
|
|
131
|
+
provableClasses: [...SUPPORTED],
|
|
132
|
+
meaning: 'provable = a proof class exists; indeterminate = declined on purpose with a stated reason; unclassified = no proof class and no decision yet (the real backlog); out-of-scope = a language the JS-only harness cannot load at all (the ceiling, not backlog).',
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Markdown for the scorecard. Every rate keeps its denominator. */
|
|
137
|
+
export function renderProofCoverage(cov) {
|
|
138
|
+
if (!cov || !cov.total) return '_No findings to report proof coverage over._\n';
|
|
139
|
+
const pct = (n) => `${n}/${cov.total} (${Math.round((n / cov.total) * 100)}%)`;
|
|
140
|
+
const lines = [
|
|
141
|
+
'| Bucket | Share | Meaning |',
|
|
142
|
+
'|---|---|---|',
|
|
143
|
+
`| Provable | ${pct(cov.provable.n)} | a proof class exists and a PoC can be attempted |`,
|
|
144
|
+
`| Indeterminate by class | ${pct(cov.indeterminate.n)} | declined on purpose — see reasons below |`,
|
|
145
|
+
`| Unclassified | ${pct(cov.unclassified.n)} | no proof class yet; the real backlog |`,
|
|
146
|
+
`| Out of harness scope | ${pct(cov.ceiling.outOfScope.n)} | not JavaScript — the harness cannot load it at all |`,
|
|
147
|
+
'',
|
|
148
|
+
// Stated every time, because the two readings of "provable" differ by a lot
|
|
149
|
+
// and a reader who sees only the first will draw the wrong conclusion.
|
|
150
|
+
`**Ceiling.** ${cov.ceiling.reason}. Of ${cov.total} findings, ${cov.ceiling.reachable.n} are reachable by the harness; `
|
|
151
|
+
+ `proof coverage is ${cov.provable.n}/${cov.total} of ALL findings and `
|
|
152
|
+
+ `${cov.provable.ofReachable.n}/${cov.provable.ofReachable.d} of the reachable ones.`,
|
|
153
|
+
'',
|
|
154
|
+
];
|
|
155
|
+
const entries = Object.entries(cov.indeterminate.byClass).sort((a, b) => b[1].n - a[1].n);
|
|
156
|
+
if (entries.length) {
|
|
157
|
+
lines.push('**Why each class is declined**', '');
|
|
158
|
+
for (const [cls, { n, reason }] of entries) lines.push(`- \`${cls}\` (${n}): ${reason}`);
|
|
159
|
+
lines.push('');
|
|
160
|
+
}
|
|
161
|
+
return lines.join('\n');
|
|
162
|
+
}
|
|
@@ -71,3 +71,47 @@ export function demoteUnreachable(findings, opts = {}) {
|
|
|
71
71
|
f._reachabilityDemoted = before;
|
|
72
72
|
}
|
|
73
73
|
}
|
|
74
|
+
|
|
75
|
+
// ── PRD F3.2 — reachability is its OWN claim, scored separately ────────────
|
|
76
|
+
//
|
|
77
|
+
// "A vulnerable version is present" and "the vulnerable FUNCTION is reachable"
|
|
78
|
+
// are different assertions with different error costs, and they were reported as
|
|
79
|
+
// one number.
|
|
80
|
+
//
|
|
81
|
+
// A false "unreachable" is a MISSED EXPLOIT — the finding is demoted to info
|
|
82
|
+
// and a real vulnerability stops being shown.
|
|
83
|
+
// A false "reachable" is noise — someone reads a finding that did not matter.
|
|
84
|
+
//
|
|
85
|
+
// Those costs are not symmetric, so a single accuracy figure covering both is
|
|
86
|
+
// the wrong instrument. This reports each separately with {n, d}, plus the
|
|
87
|
+
// DEMOTION RATE, which is the number that says how much work the reachability
|
|
88
|
+
// claim is doing: a demotion rate near zero means the feature is not earning
|
|
89
|
+
// its risk, and a high one means a great deal rests on it being right.
|
|
90
|
+
export function summarizeReachability(findings) {
|
|
91
|
+
const list = Array.isArray(findings) ? findings.filter(Boolean) : [];
|
|
92
|
+
|
|
93
|
+
// Only findings the analysis actually had an opinion about belong in the
|
|
94
|
+
// denominator. A finding with no reachability verdict is UNKNOWN, and folding
|
|
95
|
+
// unknowns into "reachable" would inflate the claim being measured.
|
|
96
|
+
const judged = list.filter((f) => f.unreachable === true || f.reachable === true || f.functionReachable != null);
|
|
97
|
+
const d = judged.length;
|
|
98
|
+
|
|
99
|
+
const demoted = judged.filter((f) => f.unreachable === true);
|
|
100
|
+
const reachable = judged.filter((f) => f.unreachable !== true);
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
total: list.length,
|
|
104
|
+
judged: { n: d, d: list.length },
|
|
105
|
+
unknown: { n: list.length - d, d: list.length },
|
|
106
|
+
reachable: { n: reachable.length, d },
|
|
107
|
+
unreachable: { n: demoted.length, d },
|
|
108
|
+
demotionRate: { n: demoted.length, d },
|
|
109
|
+
errorCosts: {
|
|
110
|
+
falseUnreachable: 'a MISSED EXPLOIT — the finding is demoted to info and a real vulnerability stops being shown',
|
|
111
|
+
falseReachable: 'noise — someone reads a finding that did not matter',
|
|
112
|
+
},
|
|
113
|
+
caveat: d === 0
|
|
114
|
+
? 'nothing was judged for reachability; every rate is 0/0 and means nothing'
|
|
115
|
+
: 'unknown is a first-class state and is NOT counted as reachable',
|
|
116
|
+
};
|
|
117
|
+
}
|
package/src/posture/sbom.js
CHANGED
|
@@ -7,24 +7,63 @@
|
|
|
7
7
|
// SPDX 2.3 schema reference: https://spdx.github.io/spdx-spec/v2.3/
|
|
8
8
|
|
|
9
9
|
import * as crypto from 'node:crypto';
|
|
10
|
+
import { isDeterministic } from './deterministic.js';
|
|
10
11
|
|
|
11
12
|
function _purl(c) {
|
|
12
13
|
if (c.purl) return c.purl;
|
|
13
14
|
const eco = c.ecosystem || 'generic';
|
|
14
15
|
const name = encodeURIComponent(c.name || '');
|
|
15
|
-
|
|
16
|
+
// Same rule as _bomRef: no version means no `@version` segment at all, not an
|
|
17
|
+
// empty or undefined one. purl consumers treat `pkg:npm/x@` as malformed.
|
|
18
|
+
const ver = c.version ? encodeURIComponent(c.version) : '';
|
|
16
19
|
// pkg:npm/<name>@<version> — pkg URL spec
|
|
17
|
-
return `pkg:${eco === 'npm' ? 'npm' : eco === 'pypi' ? 'pypi' : eco === 'maven' ? 'maven' : eco === 'cargo' ? 'cargo' : eco === 'go' ? 'golang' : eco === 'rubygems' ? 'gem' : eco === 'composer' ? 'composer' : eco}/${name}
|
|
20
|
+
return `pkg:${eco === 'npm' ? 'npm' : eco === 'pypi' ? 'pypi' : eco === 'maven' ? 'maven' : eco === 'cargo' ? 'cargo' : eco === 'go' ? 'golang' : eco === 'rubygems' ? 'gem' : eco === 'composer' ? 'composer' : eco}/${name}${ver ? `@${ver}` : ''}`;
|
|
18
21
|
}
|
|
19
22
|
|
|
20
23
|
function _bomRef(c) {
|
|
21
|
-
|
|
24
|
+
// A component with no version is ordinary — unpinned entries appear in real
|
|
25
|
+
// manifests — and the identifier must DEGRADE rather than interpolate a JS
|
|
26
|
+
// value. `npm:x@undefined` is not a version anyone can resolve, and it ships
|
|
27
|
+
// inside a document whose whole purpose is to be parsed by someone else's
|
|
28
|
+
// tooling, where it fails days later pointing at them rather than at us.
|
|
29
|
+
const eco = c.ecosystem || 'pkg';
|
|
30
|
+
const name = c.name || 'unknown';
|
|
31
|
+
return c.version ? `${eco}:${name}@${c.version}` : `${eco}:${name}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// CycloneDX `serialNumber` and SPDX `documentNamespace` are both required to
|
|
35
|
+
// identify a document, and both were minted with crypto.randomUUID() — so two
|
|
36
|
+
// scans of identical input produced different bytes, and `--deterministic` did
|
|
37
|
+
// not actually make an SBOM reproducible. An attestation over an SBOM is only
|
|
38
|
+
// meaningful if the SBOM can be regenerated and compared.
|
|
39
|
+
//
|
|
40
|
+
// Under --deterministic the identifier is derived from the document's own
|
|
41
|
+
// content instead of randomness. That preserves what the identifier is FOR:
|
|
42
|
+
// different content still yields a different id, while identical content
|
|
43
|
+
// yields an identical one — the standard reproducible-build treatment. Outside
|
|
44
|
+
// deterministic mode the random UUID is unchanged, so ordinary scans keep
|
|
45
|
+
// per-run-unique document ids.
|
|
46
|
+
function _stableUuidFrom(seed) {
|
|
47
|
+
const h = crypto.createHash('sha256').update(String(seed)).digest('hex');
|
|
48
|
+
// Shape the digest as a v4-looking UUID: the version/variant nibbles are set
|
|
49
|
+
// so consumers that validate the format still accept it.
|
|
50
|
+
return [
|
|
51
|
+
h.slice(0, 8),
|
|
52
|
+
h.slice(8, 12),
|
|
53
|
+
`4${h.slice(13, 16)}`,
|
|
54
|
+
`${((parseInt(h[16], 16) & 0x3) | 0x8).toString(16)}${h.slice(17, 20)}`,
|
|
55
|
+
h.slice(20, 32),
|
|
56
|
+
].join('-');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function _documentUuid(seed) {
|
|
60
|
+
return isDeterministic() ? _stableUuidFrom(seed) : crypto.randomUUID();
|
|
22
61
|
}
|
|
23
62
|
|
|
24
63
|
export function toCycloneDX(scan, meta = {}) {
|
|
25
64
|
const components = scan.components || [];
|
|
26
65
|
const supplyChain = (scan.supplyChain || []).filter(s => s.type === 'vulnerable_dep');
|
|
27
|
-
const serialNumber = `urn:uuid:${
|
|
66
|
+
const serialNumber = `urn:uuid:${_documentUuid(JSON.stringify(components.map(_bomRef)))}`;
|
|
28
67
|
|
|
29
68
|
const cdxComponents = components.map(c => ({
|
|
30
69
|
type: 'library',
|
|
@@ -36,8 +75,12 @@ export function toCycloneDX(scan, meta = {}) {
|
|
|
36
75
|
...(c.scope ? { scope: c.scope === 'dev' ? 'optional' : 'required' } : {}),
|
|
37
76
|
}));
|
|
38
77
|
|
|
39
|
-
const vulnerabilities = supplyChain.map(s => ({
|
|
40
|
-
|
|
78
|
+
const vulnerabilities = supplyChain.map((s, i) => ({
|
|
79
|
+
// The last-resort id was crypto.randomUUID(), which reintroduced
|
|
80
|
+
// per-run drift for any advisory carrying neither an osvId nor an
|
|
81
|
+
// advisory string. Index within the (already deterministically sorted)
|
|
82
|
+
// supplyChain array identifies it just as well and is reproducible.
|
|
83
|
+
'bom-ref': `${_bomRef({ ecosystem: s.ecosystem, name: s.name, version: s.version })}#${s.osvId || s.advisory || `unidentified-${i}`}`,
|
|
41
84
|
id: s.osvId || (s.cveAliases || [])[0] || s.advisory,
|
|
42
85
|
source: { name: 'OSV.dev', url: `https://osv.dev/vulnerability/${s.osvId || ''}` },
|
|
43
86
|
references: (s.cveAliases || []).map(cve => ({ id: cve, source: { name: 'NVD' } })),
|
|
@@ -72,7 +115,7 @@ export function toCycloneDX(scan, meta = {}) {
|
|
|
72
115
|
export function toSPDX(scan, meta = {}) {
|
|
73
116
|
const components = scan.components || [];
|
|
74
117
|
const supplyChain = (scan.supplyChain || []).filter(s => s.type === 'vulnerable_dep');
|
|
75
|
-
const docNamespace = `https://agentic-security.local/spdx/${
|
|
118
|
+
const docNamespace = `https://agentic-security.local/spdx/${_documentUuid(JSON.stringify(components.map(_bomRef)))}`;
|
|
76
119
|
const ts = meta.startedAt || new Date().toISOString();
|
|
77
120
|
|
|
78
121
|
const packages = components.map((c, i) => ({
|
package/src/runScan.js
CHANGED
|
@@ -4,7 +4,7 @@ import * as fs from 'node:fs/promises';
|
|
|
4
4
|
import * as path from 'node:path';
|
|
5
5
|
import * as cp from 'node:child_process';
|
|
6
6
|
import { listFiles } from './util/glob.js';
|
|
7
|
-
import { runFullScan, shouldScan, isKubernetesManifest } from './engine.js';
|
|
7
|
+
import { runFullScan, shouldScan, isKubernetesManifest, isCloudFormationTemplate, isInstructionFile } from './engine.js';
|
|
8
8
|
import { appendScanSnapshot } from './posture/security-trend.js';
|
|
9
9
|
import { recover as recoverFixHistory } from './posture/fix-history.js';
|
|
10
10
|
import { stampScan } from './posture/ruleset-version.js';
|
|
@@ -13,11 +13,55 @@ const DEP_FILE_NAMES = new Set([
|
|
|
13
13
|
'package.json','package-lock.json','yarn.lock','pnpm-lock.yaml',
|
|
14
14
|
'requirements.txt','pyproject.toml','poetry.lock','Pipfile.lock',
|
|
15
15
|
'composer.json','composer.lock','Gemfile','Gemfile.lock',
|
|
16
|
-
|
|
16
|
+
// go.sum, not just go.mod: go.mod lists what this module REQUIRES, go.sum
|
|
17
|
+
// lists what was RESOLVED — the transitive graph that is actually shipped.
|
|
18
|
+
// `_parseGoSum` and its dispatch entry have always existed in engine.js; the
|
|
19
|
+
// file simply never reached them, so Go SCA saw direct requires only.
|
|
20
|
+
// Measured by bench/sca-replay at 15 of 549 labelled vulnerable versions.
|
|
21
|
+
'go.mod','go.sum','Cargo.toml','Cargo.lock',
|
|
17
22
|
'pom.xml','build.gradle','build.gradle.kts',
|
|
18
23
|
'pubspec.yaml','pubspec.lock',
|
|
19
24
|
]);
|
|
20
25
|
|
|
26
|
+
// Python requirements files are `requirements/dev.txt`, `requirements-dev.txt`
|
|
27
|
+
// and `requirements/base.txt` at least as often as they are the bare name.
|
|
28
|
+
// pallets/flask ships `requirements/dev.txt` and scored 0 of 11 labelled
|
|
29
|
+
// vulnerabilities until this matched.
|
|
30
|
+
//
|
|
31
|
+
// Deliberately narrow. An arbitrary `.txt` reaching the PyPI parser would
|
|
32
|
+
// invent components out of prose, which is a worse failure than missing one:
|
|
33
|
+
// a false dependency is unfalsifiable noise in a supply-chain report.
|
|
34
|
+
const REQUIREMENTS_FILE = /^requirements(?:[._-][\w.-]+)?\.txt$/i;
|
|
35
|
+
const REQUIREMENTS_DIR_FILE = /(?:^|\/)requirements\/[\w.-]+\.txt$/i;
|
|
36
|
+
|
|
37
|
+
export function isDepFile(rel) {
|
|
38
|
+
const base = rel.split('/').pop();
|
|
39
|
+
if (DEP_FILE_NAMES.has(base)) return true;
|
|
40
|
+
if (REQUIREMENTS_FILE.test(base)) return true;
|
|
41
|
+
if (REQUIREMENTS_DIR_FILE.test(rel.split(path.sep).join('/'))) return true;
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Two caps, because the two kinds of file cost completely different amounts to
|
|
46
|
+
// process.
|
|
47
|
+
//
|
|
48
|
+
// A CODE file over the cap is skipped to protect the analysis path: parsing and
|
|
49
|
+
// walking an AST of a multi-megabyte generated file is where a scan goes from
|
|
50
|
+
// slow to hung.
|
|
51
|
+
//
|
|
52
|
+
// A MANIFEST is read by JSON.parse or a line loop. Applying the code cap to it
|
|
53
|
+
// bought nothing and cost everything: npm/cli's package-lock.json is 666 KB,
|
|
54
|
+
// next.js's pnpm-lock.yaml is 910 KB, magento2's composer.lock is 501 KB — so
|
|
55
|
+
// on every project large enough for supply-chain risk to matter, the lockfile
|
|
56
|
+
// was dropped and SCA silently fell back to the exact versions that happened to
|
|
57
|
+
// appear in package.json. That is DIRECT dependencies only, while the headline
|
|
58
|
+
// claim of this feature is transitive reachability.
|
|
59
|
+
//
|
|
60
|
+
// The manifest cap is larger, not absent. Reading an unbounded file into memory
|
|
61
|
+
// to parse it is how a scan becomes a denial of service against its own host.
|
|
62
|
+
const MAX_CODE_BYTES = 500_000;
|
|
63
|
+
const MAX_DEP_BYTES = 10_000_000;
|
|
64
|
+
|
|
21
65
|
const DEFAULT_IGNORE = [
|
|
22
66
|
'**/node_modules/**','**/.git/**','**/__pycache__/**','**/vendor/**',
|
|
23
67
|
'**/dist/**','**/build/**','**/.next/**','**/venv/**','**/env/**','**/.venv/**',
|
|
@@ -33,11 +77,12 @@ export async function readTree(root, { ignore = [] } = {}) {
|
|
|
33
77
|
const abs = path.join(root, rel);
|
|
34
78
|
let stat;
|
|
35
79
|
try { stat = await fs.stat(abs); } catch { continue; }
|
|
36
|
-
|
|
80
|
+
const dep = isDepFile(rel);
|
|
81
|
+
if (stat.size > (dep ? MAX_DEP_BYTES : MAX_CODE_BYTES)) continue;
|
|
37
82
|
let content;
|
|
38
83
|
try { content = await fs.readFile(abs, 'utf8'); } catch { continue; }
|
|
39
84
|
const base = path.basename(rel);
|
|
40
|
-
if (
|
|
85
|
+
if (dep) depFileContents[rel] = content;
|
|
41
86
|
// Cross-language taint module needs to see openapi/swagger specs even
|
|
42
87
|
// though they aren't "code" per se. Stash them in depFileContents so
|
|
43
88
|
// they ride through to runFullScan without polluting the SAST loop.
|
|
@@ -48,7 +93,13 @@ export async function readTree(root, { ignore = [] } = {}) {
|
|
|
48
93
|
// A Kubernetes manifest is admitted on CONTENT, not on living under a
|
|
49
94
|
// directory named k8s/ — see isKubernetesManifest. Without this the
|
|
50
95
|
// k8s-admission detector is wired into the dispatch and never invoked by it.
|
|
51
|
-
|
|
96
|
+
// BOTH gates must open, exactly as the k8s fix required: runScan admits a
|
|
97
|
+
// file here, then runFullScan re-filters the same list. Opening only one
|
|
98
|
+
// leaves the detector just as dark.
|
|
99
|
+
// A CloudFormation template is a `.yaml`/`.json` that no path predicate can
|
|
100
|
+
// recognise — same problem as a Kubernetes manifest, same fix, and the same
|
|
101
|
+
// requirement that BOTH gates open: runFullScan re-filters this exact list.
|
|
102
|
+
if (shouldScan(rel) || isKubernetesManifest(rel, content) || isCloudFormationTemplate(rel, content) || isInstructionFile(rel)) fileContents[rel] = content;
|
|
52
103
|
// Auxiliary files: .properties files are referenced by Java rules
|
|
53
104
|
// (e.g. OWASP Benchmark's benchmark.properties resolves algorithm
|
|
54
105
|
// aliases). They are not scannable for vulns themselves, but the
|