@clear-capabilities/agentic-security-scanner 0.118.2 → 0.119.1
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 +256 -0
- package/bin/agentic-security.js +78 -1
- package/dist/178.index.js +1 -1
- package/dist/384.index.js +1 -1
- package/dist/415.index.js +90 -0
- package/dist/444.index.js +89 -0
- package/dist/503.index.js +288 -0
- package/dist/637.index.js +15 -1
- package/dist/838.index.js +1 -1
- package/dist/985.index.js +5 -1
- package/dist/agentic-security.mjs +18 -18
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +13 -8
- package/src/dataflow/CLAUDE.md +1 -1
- package/src/dataflow/catalog.js +50 -11
- package/src/dataflow/engine.js +76 -1
- package/src/dataflow/implicit-flow.js +4 -2
- package/src/dataflow/index.js +9 -4
- package/src/dataflow/proof-gate.js +5 -0
- package/src/dataflow/summaries.js +19 -1
- package/src/engine.js +95 -7
- package/src/ir/parser-go.js +8 -0
- package/src/mcp/tools.js +6 -2
- package/src/posture/fix-history.js +24 -0
- package/src/posture/provenance.js +39 -0
- package/src/posture/sca-verdict.js +130 -0
- package/src/posture/secret-history.js +72 -0
- package/src/pr-delta.js +13 -0
- package/src/report/index.js +50 -0
- package/src/sast/agent-untrusted-flow.js +107 -0
- package/src/sast/api-authz.js +71 -0
- package/src/sast/cross-service.js +98 -0
- package/src/sast/event-entrypoint.js +90 -0
- package/src/sast/iac-terraform.js +107 -0
- package/src/sast/rbac-consistency.js +113 -0
- package/src/sast/wrong-context-sanitizer.js +55 -0
- package/src/sca/image-packages.js +74 -0
- package/src/sca/import-reachability.js +271 -0
- package/src/sca/install-script-analysis.js +82 -0
- package/bin/.agentic-security/findings.json +0 -1907
- package/bin/.agentic-security/last-scan.json +0 -1907
- package/bin/.agentic-security/last-scan.json.sig +0 -1
- package/bin/.agentic-security/scan-history.json +0 -166
- package/bin/.agentic-security/streak.json +0 -20
- package/src/.agentic-security/dpia.md +0 -26
- package/src/.agentic-security/findings.json +0 -117732
- package/src/.agentic-security/last-scan.json +0 -117732
- package/src/.agentic-security/last-scan.json.sig +0 -1
- package/src/.agentic-security/pqc-migration-plan.json +0 -80
- package/src/.agentic-security/pqc-migration-plan.md +0 -32
- package/src/.agentic-security/sbom-history/0775d96ba4cf814520d02760cfb3010760c7684f.json +0 -6
- package/src/.agentic-security/sbom-history/29300441d3460832137a8e51a92b74068cad2f15.json +0 -6
- package/src/.agentic-security/sbom-history/3852ad2aa0e50951bed0c930c29d1861aa13d048.json +0 -6
- package/src/.agentic-security/sbom-history/3d96e2daec97e3226c9679bac6e64a89ca10bfe9.json +0 -6
- package/src/.agentic-security/sbom-history/5f0b136622fce09b469a1a6137c04d5b18652ea4.json +0 -6
- package/src/.agentic-security/sbom-history/64e30595d447b62344d0d9200760269df2095332.json +0 -6
- package/src/.agentic-security/sbom-history/71f3241902c9e7437764e3caa046ba8a3bcdd32a.json +0 -6
- package/src/.agentic-security/sbom-history/99c372cdc33ecc9dde92960b4ffadf7775860e61.json +0 -6
- package/src/.agentic-security/sbom-history/9c93b15e8cf3ff2ddb8cdae92371b82e178b463c.json +0 -6
- package/src/.agentic-security/sbom-history/a3544abf1863d71776b61302ca74d9ead2b26a9f.json +0 -6
- package/src/.agentic-security/sbom-history/dca15abdec43c508aa342db86dc5db8d7f01fbbe.json +0 -6
- package/src/.agentic-security/scan-history.json +0 -12946
- package/src/.agentic-security/streak.json +0 -21
- package/src/.agentic-security/threat-model.json +0 -3702
- package/src/.agentic-security/threat-model.md +0 -118
- package/src/dataflow/.agentic-security/dpia.md +0 -26
- package/src/dataflow/.agentic-security/findings.json +0 -6086
- package/src/dataflow/.agentic-security/last-scan.json +0 -6086
- package/src/dataflow/.agentic-security/last-scan.json.sig +0 -1
- package/src/dataflow/.agentic-security/sbom-history/15d7c4d20f9d968ebec29445c9e9b3fc6f74e6a1.json +0 -6
- package/src/dataflow/.agentic-security/sbom-history/3852ad2aa0e50951bed0c930c29d1861aa13d048.json +0 -6
- package/src/dataflow/.agentic-security/sbom-history/73166872888d9ddcbe98645af818ecc7d8917b07.json +0 -6
- package/src/dataflow/.agentic-security/sbom-history/8869fe64035bec0152d92ece961e9369d8637076.json +0 -6
- package/src/dataflow/.agentic-security/sbom-history/9c93b15e8cf3ff2ddb8cdae92371b82e178b463c.json +0 -6
- package/src/dataflow/.agentic-security/sbom-history/b7ac2dada4a9216ccfe8969fc57f988c83f46975.json +0 -6
- package/src/dataflow/.agentic-security/scan-history.json +0 -250
- package/src/dataflow/.agentic-security/streak.json +0 -21
- package/src/dataflow/.agentic-security/threat-model.json +0 -161
- package/src/dataflow/.agentic-security/threat-model.md +0 -43
- package/src/ir/.agentic-security/dpia.md +0 -26
- package/src/ir/.agentic-security/exploit-bundles.json +0 -77
- package/src/ir/.agentic-security/findings.json +0 -4011
- package/src/ir/.agentic-security/last-scan.json +0 -4011
- package/src/ir/.agentic-security/last-scan.json.sig +0 -1
- package/src/ir/.agentic-security/sbom-history/73166872888d9ddcbe98645af818ecc7d8917b07.json +0 -6
- package/src/ir/.agentic-security/sbom-history/b5f468a158f6a145aedd8680841c106b9809f16b.json +0 -6
- package/src/ir/.agentic-security/scan-history.json +0 -193
- package/src/ir/.agentic-security/streak.json +0 -20
- package/src/ir/.agentic-security/threat-model.json +0 -143
- package/src/ir/.agentic-security/threat-model.md +0 -39
- package/src/mcp/.agentic-security/findings.json +0 -8632
- package/src/mcp/.agentic-security/last-scan.json +0 -8632
- package/src/mcp/.agentic-security/last-scan.json.sig +0 -1
- package/src/mcp/.agentic-security/scan-history.json +0 -331
- package/src/mcp/.agentic-security/streak.json +0 -20
- package/src/posture/.agentic-security/dpia.md +0 -26
- package/src/posture/.agentic-security/findings.json +0 -77181
- package/src/posture/.agentic-security/last-scan.json +0 -77181
- package/src/posture/.agentic-security/last-scan.json.sig +0 -1
- package/src/posture/.agentic-security/pqc-migration-plan.json +0 -65
- package/src/posture/.agentic-security/pqc-migration-plan.md +0 -30
- package/src/posture/.agentic-security/sbom-history/0724c133d2943c87555695b5b2adcfcfc3373a2d.json +0 -6
- package/src/posture/.agentic-security/sbom-history/7d45b5e03804aac084b4a2b4dc8c6f10107d2005.json +0 -6
- package/src/posture/.agentic-security/sbom-history/99c372cdc33ecc9dde92960b4ffadf7775860e61.json +0 -6
- package/src/posture/.agentic-security/sbom-history/b7ac2dada4a9216ccfe8969fc57f988c83f46975.json +0 -6
- package/src/posture/.agentic-security/scan-history.json +0 -8904
- package/src/posture/.agentic-security/streak.json +0 -21
- package/src/posture/.agentic-security/threat-model.json +0 -2038
- package/src/posture/.agentic-security/threat-model.md +0 -73
- package/src/posture/compliance-frameworks/.agentic-security/findings.json +0 -80
- package/src/posture/compliance-frameworks/.agentic-security/last-scan.json +0 -80
- package/src/posture/compliance-frameworks/.agentic-security/last-scan.json.sig +0 -1
- package/src/posture/compliance-frameworks/.agentic-security/scan-history.json +0 -90
- package/src/posture/compliance-frameworks/.agentic-security/streak.json +0 -22
- package/src/report/.agentic-security/dpia.md +0 -26
- package/src/report/.agentic-security/sbom-history/7d45b5e03804aac084b4a2b4dc8c6f10107d2005.json +0 -6
- package/src/report/.agentic-security/sbom-history/f94be839aa6a1011f53da016416a561698b8f338.json +0 -6
- package/src/report/.agentic-security/threat-model.json +0 -7
- package/src/report/.agentic-security/threat-model.md +0 -22
- package/src/sast/.agentic-security/dpia.md +0 -26
- package/src/sast/.agentic-security/findings.json +0 -6154
- package/src/sast/.agentic-security/last-scan.json +0 -6154
- package/src/sast/.agentic-security/last-scan.json.sig +0 -1
- package/src/sast/.agentic-security/pqc-migration-plan.json +0 -65
- package/src/sast/.agentic-security/pqc-migration-plan.md +0 -30
- package/src/sast/.agentic-security/sbom-history/043a7b401c68f5a298c83d4c42e94f118ff2a89a.json +0 -6
- package/src/sast/.agentic-security/sbom-history/0775d96ba4cf814520d02760cfb3010760c7684f.json +0 -6
- package/src/sast/.agentic-security/sbom-history/20d77ad7363a82777d506dedbbb1ecfed52f094a.json +0 -6
- package/src/sast/.agentic-security/sbom-history/29300441d3460832137a8e51a92b74068cad2f15.json +0 -6
- package/src/sast/.agentic-security/sbom-history/3852ad2aa0e50951bed0c930c29d1861aa13d048.json +0 -6
- package/src/sast/.agentic-security/sbom-history/3c6b3205f04e26f7654943d465281d00e0fa395e.json +0 -6
- package/src/sast/.agentic-security/sbom-history/3d96e2daec97e3226c9679bac6e64a89ca10bfe9.json +0 -6
- package/src/sast/.agentic-security/sbom-history/5282502e090013219eb006f7e02d8c983bc29c8e.json +0 -6
- package/src/sast/.agentic-security/sbom-history/5f0b136622fce09b469a1a6137c04d5b18652ea4.json +0 -6
- package/src/sast/.agentic-security/sbom-history/64e30595d447b62344d0d9200760269df2095332.json +0 -6
- package/src/sast/.agentic-security/sbom-history/71f3241902c9e7437764e3caa046ba8a3bcdd32a.json +0 -6
- package/src/sast/.agentic-security/sbom-history/73514b485d4770f3c5ee57d87338bccd1c61d8b7.json +0 -6
- package/src/sast/.agentic-security/sbom-history/96fa584fc3134512237d58aac63d6f3778b0cbcb.json +0 -6
- package/src/sast/.agentic-security/sbom-history/99c372cdc33ecc9dde92960b4ffadf7775860e61.json +0 -6
- package/src/sast/.agentic-security/sbom-history/a3544abf1863d71776b61302ca74d9ead2b26a9f.json +0 -6
- package/src/sast/.agentic-security/sbom-history/a4a34ab8b3d758de2adc8c3dc3e42d4cb0db00a3.json +0 -6
- package/src/sast/.agentic-security/sbom-history/ac236ec3602cfc8fb133f389ec3687e409084673.json +0 -6
- package/src/sast/.agentic-security/sbom-history/b57b3e1292f1f9f0de6c5a34a436e330c78008a3.json +0 -6
- package/src/sast/.agentic-security/sbom-history/dca15abdec43c508aa342db86dc5db8d7f01fbbe.json +0 -6
- package/src/sast/.agentic-security/sbom-history/dcbfd4935922dd165093c08a1003c338eed937c5.json +0 -6
- package/src/sast/.agentic-security/sbom-history/fa017f725615b0293814344c8ef7dfeb06775ca0.json +0 -6
- package/src/sast/.agentic-security/scan-history.json +0 -941
- package/src/sast/.agentic-security/streak.json +0 -22
- package/src/sast/.agentic-security/threat-model.json +0 -816
- package/src/sast/.agentic-security/threat-model.md +0 -91
- package/src/sca/.agentic-security/findings.json +0 -7460
- package/src/sca/.agentic-security/last-scan.json +0 -7460
- package/src/sca/.agentic-security/last-scan.json.sig +0 -1
- package/src/sca/.agentic-security/scan-history.json +0 -113
- package/src/sca/.agentic-security/streak.json +0 -21
|
@@ -31,6 +31,30 @@ export function readLog(scanRoot) {
|
|
|
31
31
|
if (!fs.existsSync(fp)) return [];
|
|
32
32
|
try { return JSON.parse(fs.readFileSync(fp, 'utf8')); } catch { return []; }
|
|
33
33
|
}
|
|
34
|
+
// R25 (PRD §5): auto-fix acceptance rate. The closed loop (apply → verify →
|
|
35
|
+
// re-scan for SAST; dry-run + test-gate + rollback for SCA upgrades) already
|
|
36
|
+
// exists; this surfaces the OUTCOME metric the PRD asks to track — the fraction
|
|
37
|
+
// of RESOLVED fix attempts that landed and stuck. Pure aggregation over the
|
|
38
|
+
// fix-history log; still-'pending' entries are excluded from the denominator.
|
|
39
|
+
export function acceptanceFromEntries(entries) {
|
|
40
|
+
const counts = { applied: 0, pending: 0, reverted: 0, failed: 0, other: 0 };
|
|
41
|
+
for (const e of (Array.isArray(entries) ? entries : [])) {
|
|
42
|
+
const s = (e && e.status) || 'other';
|
|
43
|
+
if (counts[s] != null) counts[s]++; else counts.other++;
|
|
44
|
+
}
|
|
45
|
+
const accepted = counts.applied;
|
|
46
|
+
const resolved = counts.applied + counts.reverted + counts.failed;
|
|
47
|
+
return {
|
|
48
|
+
acceptanceRate: resolved > 0 ? Number((accepted / resolved).toFixed(4)) : null,
|
|
49
|
+
accepted, resolved, pending: counts.pending,
|
|
50
|
+
total: Array.isArray(entries) ? entries.length : 0, counts,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function fixAcceptanceRate(scanRoot) {
|
|
55
|
+
return acceptanceFromEntries(readLog(scanRoot));
|
|
56
|
+
}
|
|
57
|
+
|
|
34
58
|
function writeLog(scanRoot, log) {
|
|
35
59
|
ensure(scanRoot);
|
|
36
60
|
fs.writeFileSync(logPath(scanRoot), JSON.stringify(log, null, 2));
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// R17 (PRD §5) — finding provenance: "one issue, many signals".
|
|
2
|
+
//
|
|
3
|
+
// dedupeFindingsWithEvidence() already collapses findings that share a
|
|
4
|
+
// (file, sink-line, family) into ONE finding and accumulates the parsers that
|
|
5
|
+
// fired into `evidence`. This annotator turns that into an explicit, queryable
|
|
6
|
+
// corroboration signal on EVERY finding (not just merged ones):
|
|
7
|
+
//
|
|
8
|
+
// finding.corroborationCount : number of DISTINCT independent analyses that
|
|
9
|
+
// flagged this exact issue (≥1).
|
|
10
|
+
// finding.corroboration.by : sorted list of those signal sources.
|
|
11
|
+
// finding.multiSignal : true when ≥2 independent sources agree.
|
|
12
|
+
//
|
|
13
|
+
// Independent confirmation lowers the odds of a false positive, so the engine
|
|
14
|
+
// ranks multi-signal findings above single-signal ones at equal severity. It
|
|
15
|
+
// deliberately does NOT touch the calibrated `confidence` field — calibration
|
|
16
|
+
// is fit/measured held-out (see posture/CLAUDE.md) and must not be perturbed by
|
|
17
|
+
// a ranking heuristic.
|
|
18
|
+
|
|
19
|
+
export function annotateFindingProvenance(findings) {
|
|
20
|
+
if (!Array.isArray(findings)) return findings;
|
|
21
|
+
for (const f of findings) {
|
|
22
|
+
if (!f || typeof f !== 'object') continue;
|
|
23
|
+
const sources = new Set();
|
|
24
|
+
if (f.parser) sources.add(f.parser);
|
|
25
|
+
// Parsers merged in by the dedup pass.
|
|
26
|
+
if (Array.isArray(f.evidence)) for (const e of f.evidence) if (e) sources.add(e);
|
|
27
|
+
// The Layer-3 LLM validator accepting a finding is an independent signal.
|
|
28
|
+
if (f.validator_verdict === 'accept') sources.add('llm-validator');
|
|
29
|
+
// A dynamically-confirmed finding (reserved for the DAST loop) is the
|
|
30
|
+
// strongest independent signal.
|
|
31
|
+
if (f.dynamicallyConfirmed === true) sources.add('dynamic');
|
|
32
|
+
if (sources.size === 0) sources.add(f.parser || 'UNKNOWN');
|
|
33
|
+
const by = [...sources].sort();
|
|
34
|
+
f.corroboration = { count: by.length, by };
|
|
35
|
+
f.corroborationCount = by.length;
|
|
36
|
+
if (by.length >= 2) f.multiSignal = true;
|
|
37
|
+
}
|
|
38
|
+
return findings;
|
|
39
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// R12 (PRD §5) — deterministic SCA decision-first verdict.
|
|
2
|
+
//
|
|
3
|
+
// Turns the enrichment signals already on each `vulnerable_dep` finding
|
|
4
|
+
// (compositeRiskTier, kev, epss, reachabilityTier, mitigationVerdict,
|
|
5
|
+
// fixedVersions, policy suppression/freeze) into ONE verdict from a closed enum,
|
|
6
|
+
// so the default SCA output is a decision ("fix these 5, auto-merge these 40,
|
|
7
|
+
// ignore these 255 — here's why") rather than a CVE dump.
|
|
8
|
+
//
|
|
9
|
+
// This codifies the ordered procedure documented for the `sca-triager` agent so
|
|
10
|
+
// it runs without an LLM. The agent remains for nuanced/uncertain cases; this
|
|
11
|
+
// gives every dep a defensible default verdict deterministically.
|
|
12
|
+
//
|
|
13
|
+
// Verdict enum (must match the agent + downstream /fix --sca):
|
|
14
|
+
// AUTO_MERGE_PATCH · WAIT_FOR_PATCH · MANUAL_REVIEW · ACCEPT_RISK · WONT_FIX
|
|
15
|
+
|
|
16
|
+
export const SCA_VERDICTS = ['AUTO_MERGE_PATCH', 'WAIT_FOR_PATCH', 'MANUAL_REVIEW', 'ACCEPT_RISK', 'WONT_FIX'];
|
|
17
|
+
|
|
18
|
+
const REACHABLE_TIERS = new Set(['route-reachable-via-function', 'function-reachable', 'import-reachable']);
|
|
19
|
+
const UNREACHABLE_TIERS = new Set(['unreachable', 'build-only', 'manifest-only', 'transitive-only']);
|
|
20
|
+
|
|
21
|
+
// Parse a version into {major,minor,patch}. Tolerates leading 'v', ranges, and
|
|
22
|
+
// pre-release/build suffixes. Returns null when no leading numeric core exists.
|
|
23
|
+
export function parseVersion(v) {
|
|
24
|
+
if (v == null) return null;
|
|
25
|
+
const m = String(v).match(/(\d+)(?:\.(\d+))?(?:\.(\d+))?/);
|
|
26
|
+
if (!m) return null;
|
|
27
|
+
return { major: +m[1], minor: m[2] != null ? +m[2] : 0, patch: m[3] != null ? +m[3] : 0 };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Classify the smallest bump from `current` to `fixed`: 'patch' | 'minor' |
|
|
31
|
+
// 'major' | 'none' (fixed <= current) | 'unknown' (unparseable).
|
|
32
|
+
export function bumpKind(current, fixed) {
|
|
33
|
+
const c = parseVersion(current);
|
|
34
|
+
const f = parseVersion(fixed);
|
|
35
|
+
if (!c || !f) return 'unknown';
|
|
36
|
+
if (f.major > c.major) return 'major';
|
|
37
|
+
if (f.major < c.major) return 'none';
|
|
38
|
+
if (f.minor > c.minor) return 'minor';
|
|
39
|
+
if (f.minor < c.minor) return 'none';
|
|
40
|
+
if (f.patch > c.patch) return 'patch';
|
|
41
|
+
return 'none';
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function isReachable(sc) {
|
|
45
|
+
// Default to "reachable" when the tier is unknown — fail toward review, not
|
|
46
|
+
// toward auto-accept (never silently ACCEPT_RISK something we couldn't place).
|
|
47
|
+
if (sc.reachabilityTier == null) return true;
|
|
48
|
+
return REACHABLE_TIERS.has(sc.reachabilityTier);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Compute the verdict for one vulnerable_dep finding. Pure. Returns
|
|
53
|
+
* { verdict, reason, expiryDays? }. Follows the documented ordered procedure;
|
|
54
|
+
* the first matching rule wins.
|
|
55
|
+
*/
|
|
56
|
+
export function computeScaVerdict(sc, opts = {}) {
|
|
57
|
+
const testsDetected = !!opts.testsDetected;
|
|
58
|
+
const tier = sc.reachabilityTier;
|
|
59
|
+
const fixed = Array.isArray(sc.fixedVersions) ? sc.fixedVersions.filter(Boolean) : [];
|
|
60
|
+
const riskTier = sc.compositeRiskTier;
|
|
61
|
+
const bump = fixed.length ? bumpKind(sc.version, fixed[0]) : null;
|
|
62
|
+
|
|
63
|
+
// 1. Already suppressed by policy (accept-risk match) → pass-through.
|
|
64
|
+
if (sc.suppressed || sc.suppressionReason) {
|
|
65
|
+
return { verdict: 'ACCEPT_RISK', reason: sc.suppressionReason || 'matched existing accept-risk in sca-policy.yml' };
|
|
66
|
+
}
|
|
67
|
+
// 2. No fixed version exists → nothing to upgrade yet.
|
|
68
|
+
if (!fixed.length) {
|
|
69
|
+
return { verdict: 'WAIT_FOR_PATCH', reason: `no fixed version published${sc.osvId ? ` for ${sc.osvId}` : ''}` };
|
|
70
|
+
}
|
|
71
|
+
// 3. KEV-listed AND reachable → urgent. Same-major fix auto-merges; else review.
|
|
72
|
+
if ((sc.kev || sc.kevListed) && isReachable(sc)) {
|
|
73
|
+
if (bump === 'patch' || bump === 'minor') {
|
|
74
|
+
return { verdict: 'AUTO_MERGE_PATCH', reason: `KEV-listed and reachable; ${bump} bump to ${fixed[0]}` };
|
|
75
|
+
}
|
|
76
|
+
return { verdict: 'MANUAL_REVIEW', reason: 'KEV-listed but fix requires a major-version bump' };
|
|
77
|
+
}
|
|
78
|
+
// 4. Environment-mitigated in prod → accept with re-evaluation window.
|
|
79
|
+
if (sc.mitigationVerdict === 'mitigated-in-prod') {
|
|
80
|
+
return { verdict: 'ACCEPT_RISK', reason: 'mitigated in production (WAF / network policy / auth gate)', expiryDays: 90 };
|
|
81
|
+
}
|
|
82
|
+
// 5. Unreachable (in prod or by tier) → accept; low real exposure.
|
|
83
|
+
if (sc.mitigationVerdict === 'unreachable-in-prod' || (tier && UNREACHABLE_TIERS.has(tier))) {
|
|
84
|
+
return { verdict: 'ACCEPT_RISK', reason: `not reachable (${sc.mitigationVerdict || tier})`, expiryDays: 180 };
|
|
85
|
+
}
|
|
86
|
+
// 6. Major-version freeze policy → human decision.
|
|
87
|
+
if (sc.majorVersionFrozen) {
|
|
88
|
+
return { verdict: 'MANUAL_REVIEW', reason: 'package is on the major-version-freeze list' };
|
|
89
|
+
}
|
|
90
|
+
// 7. Fix requires a major bump → breaking-change review.
|
|
91
|
+
if (bump === 'major') {
|
|
92
|
+
return { verdict: 'MANUAL_REVIEW', reason: `fix requires a major-version bump (${sc.version} → ${fixed[0]}); review breaking changes` };
|
|
93
|
+
}
|
|
94
|
+
// 8. Patch-only bump at high/critical risk → safe auto-merge.
|
|
95
|
+
if (bump === 'patch' && (riskTier === 'high' || riskTier === 'critical')) {
|
|
96
|
+
return { verdict: 'AUTO_MERGE_PATCH', reason: `patch bump to ${fixed[0]} at ${riskTier} risk` };
|
|
97
|
+
}
|
|
98
|
+
// 9. EPSS says exploited-now + reachable + same-major → urgency overrides minor/patch distinction.
|
|
99
|
+
if ((sc.exploitedNow || (typeof sc.epssPercentile === 'number' && sc.epssPercentile >= 0.95)) && isReachable(sc) && (bump === 'patch' || bump === 'minor')) {
|
|
100
|
+
return { verdict: 'AUTO_MERGE_PATCH', reason: `high EPSS (exploited in the wild) and reachable; ${bump} bump to ${fixed[0]}` };
|
|
101
|
+
}
|
|
102
|
+
// 10. Minor bump at critical risk WITH tests present → auto-merge (tests catch breakage).
|
|
103
|
+
if (bump === 'minor' && riskTier === 'critical' && testsDetected) {
|
|
104
|
+
return { verdict: 'AUTO_MERGE_PATCH', reason: `minor bump to ${fixed[0]} at critical risk; project has tests` };
|
|
105
|
+
}
|
|
106
|
+
// 11. Anything else (minor bump at non-critical risk, 'none', unclear) → human.
|
|
107
|
+
return { verdict: 'MANUAL_REVIEW', reason: bump === 'none' ? 'no upgrade path resolves the advisory; review manually' : `${bump || 'unclear'} bump — defer to human triage` };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Annotate every vulnerable_dep in supplyChain with `scaVerdict` +
|
|
112
|
+
* `scaVerdictReason` (+ `scaVerdictExpiryDays` when applicable). Mutates and
|
|
113
|
+
* returns supplyChain. Returns a per-verdict count map on `supplyChain._scaVerdictCounts`.
|
|
114
|
+
*/
|
|
115
|
+
export function annotateScaVerdicts(supplyChain, opts = {}) {
|
|
116
|
+
if (!Array.isArray(supplyChain)) return supplyChain;
|
|
117
|
+
const counts = { AUTO_MERGE_PATCH: 0, WAIT_FOR_PATCH: 0, MANUAL_REVIEW: 0, ACCEPT_RISK: 0, WONT_FIX: 0 };
|
|
118
|
+
for (const sc of supplyChain) {
|
|
119
|
+
if (!sc || sc.type !== 'vulnerable_dep') continue;
|
|
120
|
+
const { verdict, reason, expiryDays } = computeScaVerdict(sc, opts);
|
|
121
|
+
sc.scaVerdict = verdict;
|
|
122
|
+
sc.scaVerdictReason = reason;
|
|
123
|
+
if (expiryDays) sc.scaVerdictExpiryDays = expiryDays;
|
|
124
|
+
if (counts[verdict] != null) counts[verdict]++;
|
|
125
|
+
}
|
|
126
|
+
try {
|
|
127
|
+
Object.defineProperty(supplyChain, '_scaVerdictCounts', { value: counts, enumerable: false, configurable: true });
|
|
128
|
+
} catch { /* frozen array — ignore */ }
|
|
129
|
+
return supplyChain;
|
|
130
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// R15 (PRD §5) — git-history secret sweep.
|
|
2
|
+
//
|
|
3
|
+
// A secret removed from HEAD but present in any past commit is still
|
|
4
|
+
// recoverable from `.git` and must be rotated — the most dangerous secret case,
|
|
5
|
+
// and one a working-tree-only scan misses entirely. This sweeps recent history
|
|
6
|
+
// (bounded), feeding the ADDED lines of each commit through the same credential
|
|
7
|
+
// detector the file scan uses.
|
|
8
|
+
//
|
|
9
|
+
// The detector is INJECTED (detectFn) rather than imported, so this module has
|
|
10
|
+
// no dependency back into engine.js (avoids a circular import) and the parsing
|
|
11
|
+
// stays pure + unit-testable. Live-credential validation (is the key active?)
|
|
12
|
+
// needs network and is deferred — see the rollup.
|
|
13
|
+
|
|
14
|
+
import { execFileSync } from 'node:child_process';
|
|
15
|
+
|
|
16
|
+
// Pull the post-image (added) lines out of a unified diff: lines starting with
|
|
17
|
+
// a single '+' (not the '+++' file header). Returns reconstructed text.
|
|
18
|
+
export function extractAddedLines(diffText) {
|
|
19
|
+
const out = [];
|
|
20
|
+
for (const line of String(diffText || '').split('\n')) {
|
|
21
|
+
if (line.startsWith('+') && !line.startsWith('+++')) out.push(line.slice(1));
|
|
22
|
+
}
|
|
23
|
+
return out.join('\n');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Pure: run the injected credential detector over a commit's added lines.
|
|
27
|
+
// detectFn has the scanCredentials(fp, raw) shape and returns Finding[].
|
|
28
|
+
export function scanHistoryDiff(diffText, commit, detectFn) {
|
|
29
|
+
if (typeof detectFn !== 'function') return [];
|
|
30
|
+
const added = extractAddedLines(diffText);
|
|
31
|
+
if (!added.trim()) return [];
|
|
32
|
+
let findings = [];
|
|
33
|
+
try { findings = detectFn(`git-history@${commit}`, added) || []; } catch { return []; }
|
|
34
|
+
return findings.map((f) => ({
|
|
35
|
+
...f,
|
|
36
|
+
id: `secret-history:${commit}:${f.id || f.vuln || 'secret'}`,
|
|
37
|
+
file: `git-history@${commit}`,
|
|
38
|
+
line: 0,
|
|
39
|
+
commit,
|
|
40
|
+
_historical: true,
|
|
41
|
+
vuln: `${f.vuln || 'Hardcoded Secret'} (in git history)`,
|
|
42
|
+
description: `${f.description || 'A credential was committed.'} Found in commit ${commit}; even if removed from HEAD it remains recoverable from git and must be rotated.`,
|
|
43
|
+
remediation: 'Rotate the credential now, then purge it from history (git filter-repo / BFG) and move it to a secrets manager. Removing it from HEAD alone is insufficient.',
|
|
44
|
+
}));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Sweep up to `maxCommits` of recent history for secrets. Best-effort: returns
|
|
49
|
+
* [] when `scanRoot` is not a git repo or git is unavailable. Dedups a secret
|
|
50
|
+
* that recurs across commits to its earliest sighting.
|
|
51
|
+
*/
|
|
52
|
+
export function sweepGitHistory(scanRoot, detectFn, { maxCommits = 50, timeoutMs = 20000 } = {}) {
|
|
53
|
+
if (!scanRoot || typeof detectFn !== 'function') return [];
|
|
54
|
+
let out;
|
|
55
|
+
try {
|
|
56
|
+
out = execFileSync('git', ['-C', scanRoot, 'log', '-p', '-n', String(maxCommits), '--no-color', '--no-merges', '--no-textconv'],
|
|
57
|
+
{ encoding: 'utf8', maxBuffer: 96 * 1024 * 1024, timeout: timeoutMs, stdio: ['ignore', 'pipe', 'ignore'] });
|
|
58
|
+
} catch { return []; }
|
|
59
|
+
const parts = out.split(/^commit ([0-9a-f]{7,40})/m); // [pre, sha, body, sha, body, ...]
|
|
60
|
+
const findings = [];
|
|
61
|
+
const seen = new Set();
|
|
62
|
+
for (let i = 1; i < parts.length; i += 2) {
|
|
63
|
+
const sha = (parts[i] || '').slice(0, 12);
|
|
64
|
+
for (const f of scanHistoryDiff(parts[i + 1] || '', sha, detectFn)) {
|
|
65
|
+
const key = `${f.vuln}:${(f.snippet || f.match || '').slice(0, 40)}`;
|
|
66
|
+
if (seen.has(key)) continue;
|
|
67
|
+
seen.add(key);
|
|
68
|
+
findings.push(f);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return findings;
|
|
72
|
+
}
|
package/src/pr-delta.js
CHANGED
|
@@ -170,6 +170,19 @@ export async function computePrDelta(root, { baseRef, headRef = 'HEAD' } = {}) {
|
|
|
170
170
|
/**
|
|
171
171
|
* Lightweight text summary used as a CLI fallback when --json isn't set.
|
|
172
172
|
*/
|
|
173
|
+
// R24 (PRD §5) — net-new CI gate. Blocks the build ONLY on findings the PR
|
|
174
|
+
// INTRODUCED (delta.introduced), at or above `failOn` severity — never on the
|
|
175
|
+
// pre-existing backlog. This is the only SAST CI posture teams tolerate
|
|
176
|
+
// long-term. Pure + deterministic: takes a delta, returns the gate decision.
|
|
177
|
+
const _NETNEW_SEV_RANK = { critical: 3, high: 2, medium: 1, low: 0, info: -1 };
|
|
178
|
+
export function netNewGate(delta, failOn = 'critical') {
|
|
179
|
+
const introduced = (delta && Array.isArray(delta.introduced)) ? delta.introduced : [];
|
|
180
|
+
if (failOn === 'none') return { fail: false, failOn, introducedCount: introduced.length, blocked: [] };
|
|
181
|
+
const threshold = _NETNEW_SEV_RANK[failOn] ?? 3;
|
|
182
|
+
const blocked = introduced.filter((f) => (_NETNEW_SEV_RANK[f.severity] ?? -1) >= threshold);
|
|
183
|
+
return { fail: blocked.length > 0, failOn, introducedCount: introduced.length, blocked };
|
|
184
|
+
}
|
|
185
|
+
|
|
173
186
|
export function renderPrDeltaText(delta) {
|
|
174
187
|
const i = delta.summary.introduced;
|
|
175
188
|
const r = delta.summary.resolved;
|
package/src/report/index.js
CHANGED
|
@@ -44,6 +44,15 @@ export function normalizeFindings(scan){
|
|
|
44
44
|
dataClasses: f.dataClasses || [],
|
|
45
45
|
chain: Array.isArray(f.chain) ? f.chain : null,
|
|
46
46
|
confidence: typeof f.confidence === 'number' ? f.confidence : null,
|
|
47
|
+
// R17: corroboration ("one issue, many signals") — count of independent
|
|
48
|
+
// analyses that agreed, and which ones.
|
|
49
|
+
corroborationCount: typeof f.corroborationCount === 'number' ? f.corroborationCount : null,
|
|
50
|
+
corroboration: f.corroboration || null,
|
|
51
|
+
multiSignal: f.multiSignal === true ? true : null,
|
|
52
|
+
// R13: proof verdict — `provablySafe` means a flow proof discharged this
|
|
53
|
+
// finding (parameterizer on every path / sanitizer excludes metachars).
|
|
54
|
+
proof: f.proof || null,
|
|
55
|
+
provablySafe: f.provablySafe === true ? true : null,
|
|
47
56
|
// 0.6.0 Feat-2
|
|
48
57
|
toxicity: f.toxicityScore ?? null,
|
|
49
58
|
toxicityFactors: f.toxicityFactors || null,
|
|
@@ -630,6 +639,47 @@ function _esc(s) {
|
|
|
630
639
|
.replace(/"/g, '"').replace(/'/g, ''');
|
|
631
640
|
}
|
|
632
641
|
|
|
642
|
+
// R11 (PRD §5) — OpenVEX export. Turns our reachability verdict (R7/R12) into a
|
|
643
|
+
// standard interchange statement per CVE: unreachable tiers → not_affected with
|
|
644
|
+
// the `vulnerable_code_not_in_execute_path` justification; reachable tiers →
|
|
645
|
+
// affected with an action statement; unknown → under_investigation. Lets
|
|
646
|
+
// downstream tools and auditors inherit "this CVE can't fire here" in a format
|
|
647
|
+
// they accept, instead of re-triaging our transitive noise.
|
|
648
|
+
const _VEX_REACHABLE = new Set(['route-reachable-via-function', 'function-reachable', 'import-reachable']);
|
|
649
|
+
const _VEX_NOT_AFFECTED = new Set(['unreachable', 'build-only', 'manifest-only', 'transitive-only']);
|
|
650
|
+
export function toVex(scan, meta = {}) {
|
|
651
|
+
const deps = (scan.supplyChain || []).filter(s => s && s.type === 'vulnerable_dep');
|
|
652
|
+
const statements = [];
|
|
653
|
+
for (const s of deps) {
|
|
654
|
+
const cves = (Array.isArray(s.cveAliases) && s.cveAliases.length ? s.cveAliases : [s.osvId]).filter(Boolean);
|
|
655
|
+
if (!cves.length) continue;
|
|
656
|
+
const purl = s.purl || `pkg:${s.ecosystem || 'generic'}/${s.name}@${s.version || '0'}`;
|
|
657
|
+
const stmt = {};
|
|
658
|
+
if (_VEX_NOT_AFFECTED.has(s.reachabilityTier)) {
|
|
659
|
+
stmt.status = 'not_affected';
|
|
660
|
+
stmt.justification = 'vulnerable_code_not_in_execute_path';
|
|
661
|
+
stmt.impact_statement = `agentic-security reachability: ${s.reachabilityTier}`;
|
|
662
|
+
} else if (_VEX_REACHABLE.has(s.reachabilityTier)) {
|
|
663
|
+
stmt.status = 'affected';
|
|
664
|
+
stmt.action_statement = s.scaVerdictReason || s.remediation ||
|
|
665
|
+
(Array.isArray(s.fixedVersions) && s.fixedVersions.length ? `upgrade ${s.name} to ${s.fixedVersions[0]}` : 'review and remediate');
|
|
666
|
+
} else {
|
|
667
|
+
stmt.status = 'under_investigation';
|
|
668
|
+
}
|
|
669
|
+
for (const cve of cves) {
|
|
670
|
+
statements.push({ vulnerability: { name: cve }, products: [{ '@id': purl }], ...stmt });
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
return {
|
|
674
|
+
'@context': 'https://openvex.dev/ns/v0.2.0',
|
|
675
|
+
'@id': `https://agentic-security/vex/${(meta && meta.scanId) || 'scan'}`,
|
|
676
|
+
author: 'agentic-security',
|
|
677
|
+
timestamp: (meta && meta.startedAt) || new Date().toISOString(),
|
|
678
|
+
version: 1,
|
|
679
|
+
statements,
|
|
680
|
+
};
|
|
681
|
+
}
|
|
682
|
+
|
|
633
683
|
export function toHTML(scan, meta = {}) {
|
|
634
684
|
const findings = normalizeFindings(scan);
|
|
635
685
|
const counts = { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// R20 (PRD §5) — agent-loop taint: untrusted content → high-privilege tool.
|
|
2
|
+
//
|
|
3
|
+
// The agent threat that pattern matchers and ordinary taint both miss: content
|
|
4
|
+
// the model does NOT control — a retrieved RAG document, a tool's output — is
|
|
5
|
+
// fed into a side-effecting sink (shell/exec/eval). That's indirect prompt
|
|
6
|
+
// injection escalating to code/command execution (OWASP LLM01 + Excessive
|
|
7
|
+
// Agency). agent-tool-escalation.js flags the read+act tool COMBINATION; this
|
|
8
|
+
// flags the actual DATA FLOW from an untrusted source into the sink, and only
|
|
9
|
+
// when there is no mediation step (human approval, allow-list, sanitizer)
|
|
10
|
+
// between them.
|
|
11
|
+
//
|
|
12
|
+
// Precision-first, like the other structural detectors: it runs only in
|
|
13
|
+
// agent/LLM/RAG files, tracks untrusted source variables (+ one assignment
|
|
14
|
+
// hop), and suppresses when a mediation token sits between source and sink.
|
|
15
|
+
|
|
16
|
+
import { blankComments } from './_comment-strip.js';
|
|
17
|
+
|
|
18
|
+
const AGENT_CONTEXT = /(retriever|vectorstore|vector_store|similarity_search|get_relevant_documents|langchain|llama_?index|page_content|@tool\b|tool_use|input_schema|ChatOpenAI|ChatAnthropic|create_react_agent|AgentExecutor|\.invoke\s*\()/i;
|
|
19
|
+
|
|
20
|
+
// RHS APIs whose return value is attacker-influenceable retrieved content.
|
|
21
|
+
const RETRIEVAL_API = /\b(get_relevant_documents|aget_relevant_documents|similarity_search(?:_with_score|_with_relevance_scores)?|max_marginal_relevance_search)\s*\(/;
|
|
22
|
+
|
|
23
|
+
// A var assignment whose RHS calls a retrieval API → the LHS is untrusted.
|
|
24
|
+
const SRC_ASSIGN = /(?:\b(?:const|let|var)\s+)?([A-Za-z_$][\w$]*)\s*=\s*(?:await\s+)?[^\n;=]*?\b(?:get_relevant_documents|aget_relevant_documents|similarity_search(?:_with_score|_with_relevance_scores)?|max_marginal_relevance_search)\s*\(/g;
|
|
25
|
+
|
|
26
|
+
// Untrusted member access that needs no variable tracking (the doc body).
|
|
27
|
+
const UNTRUSTED_MEMBER = /\.(?:page_content|text)\b/;
|
|
28
|
+
|
|
29
|
+
// High-privilege / side-effecting sinks (code + command execution).
|
|
30
|
+
const SINKS = [
|
|
31
|
+
{ re: /\b(?:exec|execSync|execFile|execFileSync|spawn|spawnSync)\s*\(/, label: 'child_process exec/spawn' },
|
|
32
|
+
{ re: /\beval\s*\(/, label: 'eval()' },
|
|
33
|
+
{ re: /\bnew\s+Function\s*\(/, label: 'new Function()' },
|
|
34
|
+
{ re: /\bos\.system\s*\(/, label: 'os.system()' },
|
|
35
|
+
{ re: /\bsubprocess\.(?:run|call|check_output|check_call|Popen)\s*\(/, label: 'subprocess' },
|
|
36
|
+
{ re: /\b(?:Popen|check_output|getoutput|getstatusoutput)\s*\(/, label: 'subprocess' },
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
// A mediation/guard step between source and sink defuses the flow.
|
|
40
|
+
// Substring match (NOT word-bounded): a guard token inside a compound
|
|
41
|
+
// identifier — human_in_the_loop_approve, approveCommand, sanitizeInput,
|
|
42
|
+
// isSafeCommand — must still count as mediation.
|
|
43
|
+
const MEDIATION = /(approve|approval|confirm|human_in_the_loop|human_input|interrupt|require_consent|consent|allow_?list|whitelist|sanitiz|validate_command|validate_input|is_safe|safe_to_run|safeguard|deny|reject)/i;
|
|
44
|
+
|
|
45
|
+
function lineOf(code, idx) { return code.slice(0, idx).split('\n').length; }
|
|
46
|
+
|
|
47
|
+
export function scanAgentUntrustedFlow(fp, raw) {
|
|
48
|
+
if (typeof raw !== 'string' || !raw) return [];
|
|
49
|
+
if (!/\.(?:js|jsx|ts|tsx|mjs|cjs|py)$/i.test(fp)) return [];
|
|
50
|
+
const code = blankComments(raw);
|
|
51
|
+
if (!AGENT_CONTEXT.test(code)) return [];
|
|
52
|
+
const lines = code.split('\n');
|
|
53
|
+
|
|
54
|
+
// 1. Untrusted source variables (retrieval-API results) + one assignment hop.
|
|
55
|
+
const untrusted = new Map(); // varName -> defining line
|
|
56
|
+
let m;
|
|
57
|
+
SRC_ASSIGN.lastIndex = 0;
|
|
58
|
+
while ((m = SRC_ASSIGN.exec(code))) untrusted.set(m[1], lineOf(code, m.index));
|
|
59
|
+
// One hop: `X = <expr referencing an untrusted var or .page_content/.text>`.
|
|
60
|
+
const HOP = /(?:\b(?:const|let|var)\s+)?([A-Za-z_$][\w$]*)\s*=\s*([^\n;]+)/g;
|
|
61
|
+
for (let pass = 0; pass < 2; pass++) {
|
|
62
|
+
HOP.lastIndex = 0;
|
|
63
|
+
while ((m = HOP.exec(code))) {
|
|
64
|
+
const lhs = m[1], rhs = m[2];
|
|
65
|
+
if (untrusted.has(lhs)) continue;
|
|
66
|
+
if (UNTRUSTED_MEMBER.test(rhs) || [...untrusted.keys()].some((v) => new RegExp(`\\b${v}\\b`).test(rhs))) {
|
|
67
|
+
untrusted.set(lhs, lineOf(code, m.index));
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
if (!untrusted.size && !UNTRUSTED_MEMBER.test(code)) return [];
|
|
72
|
+
|
|
73
|
+
const findings = [];
|
|
74
|
+
const seen = new Set();
|
|
75
|
+
for (let i = 0; i < lines.length; i++) {
|
|
76
|
+
const line = lines[i];
|
|
77
|
+
for (const sink of SINKS) {
|
|
78
|
+
if (!sink.re.test(line)) continue;
|
|
79
|
+
// Does the sink's argument reference untrusted content?
|
|
80
|
+
const argText = line.slice(line.search(sink.re));
|
|
81
|
+
const hitsMember = UNTRUSTED_MEMBER.test(argText);
|
|
82
|
+
const srcVar = [...untrusted.keys()].find((v) => new RegExp(`\\b${v}\\b`).test(argText));
|
|
83
|
+
if (!hitsMember && !srcVar) continue;
|
|
84
|
+
// Mediation between the source definition and this sink suppresses it.
|
|
85
|
+
const srcLine = srcVar ? untrusted.get(srcVar) : Math.max(1, i - 8);
|
|
86
|
+
const window = lines.slice(Math.max(0, srcLine - 1), i + 1).join('\n');
|
|
87
|
+
if (MEDIATION.test(window)) continue;
|
|
88
|
+
const key = `${i + 1}:${sink.label}`;
|
|
89
|
+
if (seen.has(key)) continue;
|
|
90
|
+
seen.add(key);
|
|
91
|
+
findings.push({
|
|
92
|
+
id: `agent-untrusted-flow:${fp}:${i + 1}`,
|
|
93
|
+
severity: 'high',
|
|
94
|
+
file: fp,
|
|
95
|
+
line: i + 1,
|
|
96
|
+
vuln: 'Indirect Prompt Injection → Code/Command Execution (untrusted agent content reaches a high-privilege sink)',
|
|
97
|
+
cwe: '94',
|
|
98
|
+
owaspLlm: 'LLM01',
|
|
99
|
+
family: 'agent-untrusted-flow',
|
|
100
|
+
parser: 'AGENT-FLOW',
|
|
101
|
+
description: `Untrusted agent content (${srcVar ? `via \`${srcVar}\`` : 'a retrieved document body'}) flows into ${sink.label} with no mediation step. A poisoned document or tool output can drive code/command execution (indirect prompt injection → excessive agency).`,
|
|
102
|
+
remediation: 'Never pass retrieved/tool-derived content to a code/command sink. Require a human-approval/allow-list/sanitizer mediation step, or constrain the tool to a fixed, non-interpolated command.',
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return findings;
|
|
107
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// R19 (PRD §5) — OWASP API Top 10: Broken Object/Function Level Authorization.
|
|
2
|
+
//
|
|
3
|
+
// The data-layer IDOR detector (engine.js) catches `findById(req.params.id)`
|
|
4
|
+
// without an ownership clause. This complements it at the ROUTE-INVENTORY
|
|
5
|
+
// layer, where the highest-signal tell is INCONSISTENCY: a route registered in
|
|
6
|
+
// a file whose siblings are authenticated, but which itself has no auth.
|
|
7
|
+
//
|
|
8
|
+
// BFLA (API5): a state-changing route (POST/PUT/PATCH/DELETE) with no auth
|
|
9
|
+
// while sibling routes in the same file do enforce it.
|
|
10
|
+
// BOLA (API1): a route taking an object identifier (:id / {id} / <id>) with
|
|
11
|
+
// no auth, so object-level ownership cannot be enforced.
|
|
12
|
+
//
|
|
13
|
+
// Precision: we only fire inside files that DO authenticate some routes (an
|
|
14
|
+
// authenticated API surface) — a fully-public API isn't flagged, and the
|
|
15
|
+
// inconsistency is what makes a missing check a likely bug rather than intent.
|
|
16
|
+
|
|
17
|
+
const STATE_CHANGING = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
|
|
18
|
+
const ID_PARAM = /\/(?::\w*(?:id|key|uuid|guid|slug)\w*|\{\w*(?:id|key|uuid|guid|slug)\w*\}|<\w*(?:id|key|uuid|guid|slug)\w*>)/i;
|
|
19
|
+
|
|
20
|
+
function mk(r, kind, api, cwe, why) {
|
|
21
|
+
return {
|
|
22
|
+
id: `api-authz:${kind}:${r.file}:${r.line}`,
|
|
23
|
+
severity: 'high',
|
|
24
|
+
file: r.file,
|
|
25
|
+
line: r.line || 0,
|
|
26
|
+
vuln: `Broken ${kind === 'BOLA' ? 'Object' : 'Function'} Level Authorization (${api})`,
|
|
27
|
+
cwe,
|
|
28
|
+
family: 'broken-access-control',
|
|
29
|
+
parser: 'API-AUTHZ',
|
|
30
|
+
description: why,
|
|
31
|
+
remediation: kind === 'BOLA'
|
|
32
|
+
? 'Authenticate the route and verify the caller owns the referenced object (scope the lookup by req.user.id / tenantId), don\'t trust the id from the request alone.'
|
|
33
|
+
: 'Apply the same authentication/authorization middleware the sibling routes use; state-changing endpoints must enforce a role/permission check.',
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Cross-route analysis over the aggregated route inventory (aR).
|
|
39
|
+
* Pure: takes routes[], returns Finding[].
|
|
40
|
+
*/
|
|
41
|
+
export function scanApiBrokenAuthz(routes) {
|
|
42
|
+
if (!Array.isArray(routes) || routes.length < 2) return [];
|
|
43
|
+
const byFile = new Map();
|
|
44
|
+
for (const r of routes) {
|
|
45
|
+
if (!r || r.path === '(file-based)' || !r.file) continue;
|
|
46
|
+
if (!byFile.has(r.file)) byFile.set(r.file, []);
|
|
47
|
+
byFile.get(r.file).push(r);
|
|
48
|
+
}
|
|
49
|
+
const findings = [];
|
|
50
|
+
const seen = new Set();
|
|
51
|
+
const push = (f) => { const k = `${f.file}:${f.line}`; if (seen.has(k)) return; seen.add(k); findings.push(f); };
|
|
52
|
+
for (const group of byFile.values()) {
|
|
53
|
+
if (group.length < 2) continue;
|
|
54
|
+
const authed = group.filter((r) => r.hasAuth).length;
|
|
55
|
+
// Only an authenticated API surface — at least one protected sibling, but
|
|
56
|
+
// not all (otherwise there's no inconsistency to flag).
|
|
57
|
+
if (authed < 1 || authed === group.length) continue;
|
|
58
|
+
for (const r of group) {
|
|
59
|
+
if (r.hasAuth) continue;
|
|
60
|
+
const idParam = ID_PARAM.test(r.path || '');
|
|
61
|
+
if (idParam) {
|
|
62
|
+
push(mk(r, 'BOLA', 'API1', '639',
|
|
63
|
+
`${r.method} ${r.path} takes an object identifier but enforces no authorization, while ${authed}/${group.length} sibling routes do. Object-level ownership cannot be enforced — an attacker can substitute another user's id.`));
|
|
64
|
+
} else if (STATE_CHANGING.has(r.method)) {
|
|
65
|
+
push(mk(r, 'BFLA', 'API5', '285',
|
|
66
|
+
`${r.method} ${r.path} is a state-changing route with no authorization, while ${authed}/${group.length} sibling routes in this file enforce it — a likely missing function-level access check.`));
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return findings;
|
|
71
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// R22 (PRD §5) — cross-service dataflow (contract-file-free).
|
|
2
|
+
//
|
|
3
|
+
// posture/cross-lang-* links services through a shared contract artifact
|
|
4
|
+
// (OpenAPI/proto). This infers the edge directly from CODE: an HTTP client call
|
|
5
|
+
// whose URL path matches a route registered in ANOTHER file is a service edge.
|
|
6
|
+
// When the client sends user-controlled data over that edge — especially to an
|
|
7
|
+
// UNAUTHENTICATED internal endpoint — data crosses a trust boundary unobserved.
|
|
8
|
+
//
|
|
9
|
+
// Pure cross-file analysis over the aggregated route inventory + file contents.
|
|
10
|
+
// Path matching normalizes dynamic segments (/:id, /{id}, numeric) so a literal
|
|
11
|
+
// client URL aligns with a parameterized route. Precision-first: requires a
|
|
12
|
+
// real route match AND user-controlled data in the client call.
|
|
13
|
+
|
|
14
|
+
const CLIENT_CALLS = [
|
|
15
|
+
/\bfetch\s*\(\s*[`'"]([^`'"]+)[`'"]/g,
|
|
16
|
+
/\baxios\s*\.\s*(?:get|post|put|patch|delete|request)\s*\(\s*[`'"]([^`'"]+)[`'"]/g,
|
|
17
|
+
/\baxios\s*\(\s*\{[^}]*?\burl\s*:\s*[`'"]([^`'"]+)[`'"]/g,
|
|
18
|
+
/\b(?:got|ky|superagent|needle)\s*(?:\.\s*\w+)?\s*\(\s*[`'"]([^`'"]+)[`'"]/g,
|
|
19
|
+
/\brequests\s*\.\s*(?:get|post|put|patch|delete)\s*\(\s*[`'"]([^`'"]+)[`'"]/g,
|
|
20
|
+
/\bhttpClient\s*\.\s*\w+\s*\(\s*[`'"]([^`'"]+)[`'"]/g,
|
|
21
|
+
];
|
|
22
|
+
const USER_DATA = /\b(?:req\.(?:body|params|query|headers)|request\.(?:data|json|args)|ctx\.request|params|\.body\b|userInput|user_input|formData)\b/;
|
|
23
|
+
|
|
24
|
+
// Normalize a URL path: drop scheme+host, collapse dynamic segments to '*'.
|
|
25
|
+
export function normalizePath(url) {
|
|
26
|
+
let p = String(url || '').trim();
|
|
27
|
+
p = p.replace(/^[a-z]+:\/\/[^/]+/i, ''); // strip scheme://host
|
|
28
|
+
p = p.replace(/[?#].*$/, ''); // strip query/hash
|
|
29
|
+
if (!p.startsWith('/')) p = '/' + p;
|
|
30
|
+
const segs = p.split('/').map((s) => {
|
|
31
|
+
if (/^(?::\w+|\{\w+\}|<\w+>|\*|\$\{[^}]+\}|\d+)$/.test(s)) return '*';
|
|
32
|
+
return s.toLowerCase();
|
|
33
|
+
});
|
|
34
|
+
let out = segs.join('/').replace(/\/+$/, '');
|
|
35
|
+
return out || '/';
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function lineOf(text, idx) { return text.slice(0, idx).split('\n').length; }
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* routes: aggregated route inventory (aR). fileContents: { file: text }.
|
|
42
|
+
* Returns cross-service edge findings.
|
|
43
|
+
*/
|
|
44
|
+
export function scanCrossService(routes, fileContents) {
|
|
45
|
+
if (!Array.isArray(routes) || !routes.length) return [];
|
|
46
|
+
// Index server routes by normalized path → list of {route}.
|
|
47
|
+
const byPath = new Map();
|
|
48
|
+
for (const r of routes) {
|
|
49
|
+
if (!r || r.path === '(file-based)' || !r.path || !r.path.startsWith('/')) continue;
|
|
50
|
+
const np = normalizePath(r.path);
|
|
51
|
+
if (!byPath.has(np)) byPath.set(np, []);
|
|
52
|
+
byPath.get(np).push(r);
|
|
53
|
+
}
|
|
54
|
+
if (!byPath.size) return [];
|
|
55
|
+
|
|
56
|
+
const findings = [];
|
|
57
|
+
const seen = new Set();
|
|
58
|
+
for (const [file, content] of Object.entries(fileContents || {})) {
|
|
59
|
+
if (typeof content !== 'string') continue;
|
|
60
|
+
for (const re of CLIENT_CALLS) {
|
|
61
|
+
const rx = new RegExp(re.source, re.flags);
|
|
62
|
+
let m;
|
|
63
|
+
while ((m = rx.exec(content))) {
|
|
64
|
+
const url = m[1];
|
|
65
|
+
const np = normalizePath(url);
|
|
66
|
+
const targets = byPath.get(np);
|
|
67
|
+
if (!targets) continue;
|
|
68
|
+
// Only a cross-FILE edge is interesting (same-file is a local call).
|
|
69
|
+
const remote = targets.find((t) => t.file !== file);
|
|
70
|
+
if (!remote) continue;
|
|
71
|
+
const line = lineOf(content, m.index);
|
|
72
|
+
// Is user-controlled data in the call's vicinity?
|
|
73
|
+
const ctx = content.slice(m.index, m.index + 240);
|
|
74
|
+
const carriesUserData = USER_DATA.test(ctx);
|
|
75
|
+
if (!carriesUserData) continue; // precision: only flag tainted edges
|
|
76
|
+
const key = `${file}:${line}:${np}`;
|
|
77
|
+
if (seen.has(key)) continue;
|
|
78
|
+
seen.add(key);
|
|
79
|
+
const unauthed = remote.hasAuth === false;
|
|
80
|
+
findings.push({
|
|
81
|
+
id: `cross-service:${file}:${line}`,
|
|
82
|
+
severity: unauthed ? 'high' : 'medium',
|
|
83
|
+
file, line,
|
|
84
|
+
vuln: `Cross-service flow: user-controlled data sent to ${remote.method} ${remote.path}${unauthed ? ' (unauthenticated internal endpoint)' : ''}`,
|
|
85
|
+
cwe: unauthed ? 'CWE-862' : 'CWE-668',
|
|
86
|
+
family: 'cross-service',
|
|
87
|
+
parser: 'XSERVICE',
|
|
88
|
+
description: `This client call carries user-controlled data to ${remote.method} ${remote.path}, defined in ${remote.file}. Data crosses a service boundary here${unauthed ? ', and the target route enforces no authentication — an attacker-reachable internal endpoint' : ''}. Inferred from code (no shared contract file).`,
|
|
89
|
+
remediation: unauthed
|
|
90
|
+
? 'Authenticate/authorize the internal endpoint and validate the forwarded payload at the receiving service; never trust cross-service input implicitly.'
|
|
91
|
+
: 'Validate and authorize the forwarded payload at the receiving service; treat cross-service input as untrusted.',
|
|
92
|
+
_edge: { from: file, to: remote.file, route: `${remote.method} ${remote.path}` },
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return findings;
|
|
98
|
+
}
|