@clear-capabilities/agentic-security-scanner 0.134.0 → 0.136.9
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 +432 -0
- package/bin/agentic-security-audit.js +2 -1
- package/bin/agentic-security-consistency.js +2 -1
- package/bin/agentic-security.js +448 -74
- package/dist/113.index.js +16 -7
- package/dist/117.index.js +3 -1
- package/dist/178.index.js +1 -1
- package/dist/207.index.js +5 -4
- package/dist/220.index.js +5 -3
- package/dist/238.index.js +4 -4
- package/dist/317.index.js +300 -0
- package/dist/384.index.js +1 -1
- package/dist/435.index.js +196 -21
- package/dist/444.index.js +20 -11
- package/dist/449.index.js +8 -1
- package/dist/513.index.js +7 -3
- package/dist/526.index.js +6 -6
- package/dist/637.index.js +1 -1
- package/dist/675.index.js +7 -5
- package/dist/839.index.js +4 -3
- package/dist/905.index.js +1173 -0
- package/dist/agentic-security.mjs +14 -14
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/dist/compliance-frameworks/ccpa.json +32 -0
- package/dist/compliance-frameworks/eu-ai-act.json +51 -0
- package/dist/compliance-frameworks/gdpr.json +45 -0
- package/dist/compliance-frameworks/hipaa-security-rule.json +56 -0
- package/dist/compliance-frameworks/nist-ai-600-1.json +51 -0
- package/dist/compliance-frameworks/nist-csf-2.json +73 -0
- package/dist/compliance-frameworks/nist-privacy-1-1.json +846 -0
- package/dist/compliance-frameworks/owasp-asvs-5.json +79 -0
- package/dist/compliance-frameworks/owasp-llm-top-10.json +69 -0
- package/package.json +24 -12
- package/src/badge.js +2 -1
- package/src/dataflow/CLAUDE.md +10 -4
- package/src/dataflow/builtin-summaries.js +1 -1
- package/src/dataflow/cross-service-taint.js +2 -1
- package/src/dataflow/engine.js +324 -60
- package/src/dataflow/ifds-precise.js +6 -4
- package/src/dataflow/implicit-flow.js +68 -36
- package/src/dataflow/incremental.js +25 -8
- package/src/dataflow/index.js +2 -1
- package/src/dataflow/proven-clean.js +41 -0
- package/src/dataflow/sanitizer-gate.js +35 -9
- package/src/dataflow/sanitizer-proof.js +21 -3
- package/src/dataflow/stub-aware-filter.js +36 -13
- package/src/dataflow/summaries.js +21 -2
- package/src/discovery/CLAUDE.md +10 -0
- package/src/discovery/index.js +175 -3
- package/src/discovery/llm-invoke.js +90 -1
- package/src/discovery/memory.js +163 -0
- package/src/engine.js +247 -50
- package/src/integrations/tickets.js +7 -6
- package/src/ir/CLAUDE.md +4 -1
- package/src/ir/balanced-call.js +55 -0
- package/src/ir/ir-stats.js +1 -1
- package/src/ir/parser-cpp.js +1 -1
- package/src/ir/parser-cs.js +62 -9
- package/src/ir/parser-go.js +29 -11
- package/src/ir/parser-java.js +96 -19
- package/src/ir/parser-js.js +151 -20
- package/src/ir/parser-php.js +44 -9
- package/src/ir/parser-rb.js +37 -7
- package/src/ir/ssa.js +6 -1
- package/src/leaderboard.js +3 -2
- package/src/llm-validator/consistency.js +6 -2
- package/src/llm-validator/index.js +1 -2
- package/src/lsp/server.js +28 -2
- package/src/mcp/CLAUDE.md +9 -2
- package/src/mcp/audit.js +2 -1
- package/src/mcp/redact.js +26 -0
- package/src/mcp/tools.js +159 -17
- package/src/posture/CLAUDE.md +45 -8
- package/src/posture/accuracy-scorecard.js +67 -1
- package/src/posture/agents-memory.js +5 -3
- package/src/posture/aibom.js +12 -8
- package/src/posture/auditor-walkthrough.js +111 -10
- package/src/posture/auth-posture-import.js +5 -4
- package/src/posture/autopilot.js +8 -1
- package/src/posture/calibration-drift.js +11 -5
- package/src/posture/calibration.js +24 -2
- package/src/posture/compliance-frameworks/nist-privacy-1-1.json +846 -0
- package/src/posture/compliance-frameworks/owasp-asvs-5.json +1 -1
- package/src/posture/compliance-policy.js +40 -10
- package/src/posture/confidence.js +44 -10
- package/src/posture/corpus-enroll.js +9 -5
- package/src/posture/corpus-match.js +19 -0
- package/src/posture/csharp-analysis.js +62 -3
- package/src/posture/custom-rules.js +7 -5
- package/src/posture/cve-alert-daemon.js +6 -5
- package/src/posture/dep-add-guard.js +2 -1
- package/src/posture/deploy-platform.js +4 -1
- package/src/posture/deterministic.js +3 -2
- package/src/posture/drift.js +7 -1
- package/src/posture/epss.js +13 -1
- package/src/posture/evidence-bundle.js +276 -0
- package/src/posture/exploitability-probability.js +15 -2
- package/src/posture/falsification.js +23 -2
- package/src/posture/feature-flags.js +3 -2
- package/src/posture/findings-memory.js +3 -3
- package/src/posture/fix-history.js +5 -2
- package/src/posture/fix-metrics.js +5 -5
- package/src/posture/fix-plan.js +2 -1
- package/src/posture/fix-verify-loop.js +10 -1
- package/src/posture/grader-calibration.js +3 -4
- package/src/posture/iac-reachability.js +14 -8
- package/src/posture/integrity.js +25 -7
- package/src/posture/intent-context.js +2 -1
- package/src/posture/learning.js +4 -3
- package/src/posture/license-attributions.js +5 -7
- package/src/posture/license-graph.js +2 -1
- package/src/posture/license-policy.js +2 -1
- package/src/posture/model-rescan.js +69 -3
- package/src/posture/mttr.js +5 -0
- package/src/posture/network-policy-import.js +3 -2
- package/src/posture/poc-inprocess.js +27 -8
- package/src/posture/pqc-migration-plan.js +7 -5
- package/src/posture/pr-augment.js +8 -5
- package/src/posture/privacy-framework.js +262 -0
- package/src/posture/regression-test-gen.js +23 -8
- package/src/posture/reverse-blast-radius.js +5 -1
- package/src/posture/risk-dollars.js +20 -3
- package/src/posture/router.js +5 -4
- package/src/posture/ruleset-version.js +2 -2
- package/src/posture/runtime-correlation.js +2 -1
- package/src/posture/sbom-diff.js +12 -3
- package/src/posture/sca-policy.js +7 -4
- package/src/posture/scan-checkpoint.js +15 -0
- package/src/posture/secret-history.js +20 -11
- package/src/posture/security-trend.js +7 -1
- package/src/posture/stack-playbook.js +22 -1
- package/src/posture/state-dir.js +34 -0
- package/src/posture/telemetry-ingest.js +4 -3
- package/src/posture/threat-model-auto.js +4 -1
- package/src/posture/threat-model-grounding.js +13 -3
- package/src/posture/time-to-fix.js +3 -2
- package/src/posture/triage-memory.js +3 -2
- package/src/posture/validator-metrics.js +10 -3
- package/src/posture/verifier.js +32 -57
- package/src/posture/waf-ingest.js +6 -5
- package/src/posture/watch-mode.js +4 -3
- package/src/report/index.js +183 -14
- package/src/runScan.js +1 -1
- package/src/sast/_comment-strip.js +15 -4
- package/src/sast/_secret-entropy.js +1 -1
- package/src/sast/authz.js +6 -4
- package/src/sast/bench-shape/index.js +2 -7
- package/src/sast/claude-md-prompt-injection.js +14 -3
- package/src/sast/cloud-iam.js +60 -7
- package/src/sast/code-injection-multilang.js +29 -0
- package/src/sast/cpp-bench-extras.js +1 -1
- package/src/sast/csrf.js +7 -5
- package/src/sast/env-hygiene.js +5 -2
- package/src/sast/iac-terraform.js +25 -0
- package/src/sast/java-bench-extras.js +1 -1
- package/src/sast/java-constant-fold.js +5 -5
- package/src/sast/llm-owasp.js +4 -2
- package/src/sast/mcp-audit.js +7 -0
- package/src/sast/pipeline.js +8 -0
- package/src/sast/prompt-template.js +8 -6
- package/src/sast/prototype-pollution.js +6 -2
- package/src/sast/redos-nfa.js +6 -6
- package/src/sast/secret-concat.js +13 -2
- package/src/sast/ssrf-cloud-metadata.js +6 -3
- package/src/sast/xss-reflected-multilang.js +1 -1
- package/src/sast/xxe.js +1 -1
- package/src/sca/CLAUDE.md +3 -4
- package/src/sca/container.js +35 -3
- package/src/sca/dep-confusion.js +9 -1
- package/src/sca/sarif-ingest.js +0 -187
|
@@ -66,7 +66,7 @@
|
|
|
66
66
|
"category": "Malicious Code",
|
|
67
67
|
"summary": "Verify the application does not include known-malicious or compromised dependencies.",
|
|
68
68
|
"evidence": [".agentic-security/sbom-history snapshots clean.", "No dependency-confusion or dependency-drift findings."],
|
|
69
|
-
"mapsTo": ["family:vulnerable-
|
|
69
|
+
"mapsTo": ["family:vulnerable-dep", "family:dependency-confusion", "family:dependency-drift"]
|
|
70
70
|
},
|
|
71
71
|
{
|
|
72
72
|
"id": "V14.1",
|
|
@@ -40,11 +40,13 @@
|
|
|
40
40
|
import * as fs from 'node:fs';
|
|
41
41
|
import * as path from 'node:path';
|
|
42
42
|
import * as yaml from '../util/yaml.js';
|
|
43
|
+
import { statePath, safeWriteState } from './state-dir.js';
|
|
44
|
+
import { SCANNER_VERSION } from './version.js';
|
|
43
45
|
|
|
44
46
|
const POLICY_FILE = 'compliance.policy.yml';
|
|
45
47
|
|
|
46
48
|
export function loadPolicy(scanRoot) {
|
|
47
|
-
const fp =
|
|
49
|
+
const fp = statePath(scanRoot, POLICY_FILE);
|
|
48
50
|
if (!fs.existsSync(fp)) return null;
|
|
49
51
|
try {
|
|
50
52
|
const raw = fs.readFileSync(fp, 'utf8');
|
|
@@ -105,7 +107,7 @@ function _runCheck(check, ctx) {
|
|
|
105
107
|
}
|
|
106
108
|
if (check['sca-policy-has-entry']) {
|
|
107
109
|
const type = check['sca-policy-has-entry'];
|
|
108
|
-
const policyPath =
|
|
110
|
+
const policyPath = statePath(ctx.scanRoot, 'sca-policy.yml');
|
|
109
111
|
if (!fs.existsSync(policyPath)) return { passed: false, reason: 'sca-policy.yml not found' };
|
|
110
112
|
try {
|
|
111
113
|
const policy = yaml.load(fs.readFileSync(policyPath, 'utf8'));
|
|
@@ -127,7 +129,29 @@ function _runCheck(check, ctx) {
|
|
|
127
129
|
* Run all controls in the policy and emit a verification report.
|
|
128
130
|
*/
|
|
129
131
|
export function verifyPolicy(policy, ctx) {
|
|
132
|
+
// CMP-5: loadPolicy() reports a parse failure as { _error }, which has no
|
|
133
|
+
// `.controls` — the same shape as "no policy file at all". Distinguishing
|
|
134
|
+
// them matters: a customer who typo'd their YAML deserves a loud error,
|
|
135
|
+
// not a report that silently treats their (unparsed) policy as having no
|
|
136
|
+
// controls to check.
|
|
137
|
+
if (policy && policy._error) return { controls: [], status: 'error', error: policy._error };
|
|
130
138
|
if (!policy || !policy.controls) return { controls: [], status: 'no-policy' };
|
|
139
|
+
// CMP-5: a finding-family check must see every channel a real scan
|
|
140
|
+
// produces (findings=SAST, secrets, logicVulns, supplyChain=SCA) — the
|
|
141
|
+
// caller (engine.js) hands last-scan.json's own channel split through
|
|
142
|
+
// ctx, so a control checking family:hardcoded-secret or
|
|
143
|
+
// family:vulnerable-dep isn't blind to 3 of the engine's 4 finding types.
|
|
144
|
+
// Family defaults mirror report/index.js's normalizeFindings so the two
|
|
145
|
+
// modules can't drift apart on what family an untagged finding belongs to.
|
|
146
|
+
ctx = {
|
|
147
|
+
...ctx,
|
|
148
|
+
findings: [
|
|
149
|
+
...(ctx.findings || []),
|
|
150
|
+
...(ctx.secrets || []).map(s => ({ ...s, family: s.family || 'hardcoded-secret' })),
|
|
151
|
+
...(ctx.logicVulns || []),
|
|
152
|
+
...(ctx.supplyChain || []).map(sc => ({ ...sc, family: sc.family || 'vulnerable-dep' })),
|
|
153
|
+
],
|
|
154
|
+
};
|
|
131
155
|
const results = [];
|
|
132
156
|
for (const control of policy.controls) {
|
|
133
157
|
if (control.not_applicable) {
|
|
@@ -165,6 +189,13 @@ export function emitEvidenceJsonLd(report, scanRoot) {
|
|
|
165
189
|
framework: report.framework,
|
|
166
190
|
version: report.version,
|
|
167
191
|
generatedAt: new Date().toISOString(),
|
|
192
|
+
// CMP-5: this artifact is fed to GRC tooling (Vanta/Drata/SecureFrame) and
|
|
193
|
+
// auditors largely unread by a human — the same reason
|
|
194
|
+
// auditor-walkthrough.js's narrative carries this disclaimer verbatim.
|
|
195
|
+
// Without it here, a machine-consumed "ComplianceEvidence" document reads
|
|
196
|
+
// as an attestation, not a scanner's automated observation.
|
|
197
|
+
disclaimer: 'This artifact organizes automated scanner evidence into a structured report. It does not certify compliance. A licensed assessor (CPA / auditor / DPO) is responsible for the final attestation.',
|
|
198
|
+
provenance: { engineVersion: SCANNER_VERSION },
|
|
168
199
|
summary: report.summary,
|
|
169
200
|
controls: report.controls.map(c => ({
|
|
170
201
|
'@type': 'Control',
|
|
@@ -178,10 +209,9 @@ export function emitEvidenceJsonLd(report, scanRoot) {
|
|
|
178
209
|
narrative_evidence: c.evidence || [],
|
|
179
210
|
})),
|
|
180
211
|
};
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
} catch {}
|
|
212
|
+
// Through the seam — see the note in pqc-migration-plan.js. The report is
|
|
213
|
+
// still returned when writing is off; only the artifact is withheld.
|
|
214
|
+
safeWriteState(statePath(scanRoot, 'compliance-evidence.json'), JSON.stringify(jsonld, null, 2));
|
|
185
215
|
return jsonld;
|
|
186
216
|
}
|
|
187
217
|
|
|
@@ -192,7 +222,9 @@ export function emitEvidenceMarkdown(report, scanRoot) {
|
|
|
192
222
|
const lines = [];
|
|
193
223
|
lines.push(`# Compliance evidence — ${report.framework}`);
|
|
194
224
|
lines.push('');
|
|
195
|
-
lines.push(`Generated by agentic-security on ${new Date().toISOString().slice(0,10)}.`);
|
|
225
|
+
lines.push(`Generated by agentic-security (engine ${SCANNER_VERSION}) on ${new Date().toISOString().slice(0,10)}.`);
|
|
226
|
+
lines.push('');
|
|
227
|
+
lines.push('> This document organizes automated scanner evidence into a structured report. It does not certify compliance. A licensed assessor (CPA / auditor / DPO) is responsible for the final attestation.');
|
|
196
228
|
lines.push('');
|
|
197
229
|
lines.push(`Compliant: **${report.summary.compliant}** / Non-compliant: **${report.summary.nonCompliant}** / Not applicable: **${report.summary.notApplicable}** of ${report.summary.total} controls.`);
|
|
198
230
|
lines.push('');
|
|
@@ -209,9 +241,7 @@ export function emitEvidenceMarkdown(report, scanRoot) {
|
|
|
209
241
|
}
|
|
210
242
|
lines.push('');
|
|
211
243
|
}
|
|
212
|
-
|
|
213
|
-
fs.writeFileSync(path.join(scanRoot, '.agentic-security', 'compliance-evidence.md'), lines.join('\n'));
|
|
214
|
-
} catch {}
|
|
244
|
+
safeWriteState(statePath(scanRoot, 'compliance-evidence.md'), lines.join('\n'));
|
|
215
245
|
return lines.join('\n');
|
|
216
246
|
}
|
|
217
247
|
|
|
@@ -47,19 +47,53 @@ export function annotateConfidence(findings) {
|
|
|
47
47
|
if (f.routeRooted) conf = Math.min(1, conf + 0.05);
|
|
48
48
|
if (f.guards && f.guards.length) conf *= 0.80;
|
|
49
49
|
if (f.reachable === false) conf *= 0.55;
|
|
50
|
+
// f.unvalidated is set later in the pipeline (llm-validator/index.js,
|
|
51
|
+
// invoked well after this first confidence pass), so it's never true
|
|
52
|
+
// here on a finding's first computation — see applyUnvalidatedPenalty
|
|
53
|
+
// below, the real enforcement point, run after validation. Kept here
|
|
54
|
+
// too so a caller that builds a synthetic finding with
|
|
55
|
+
// unvalidated:true pre-set still gets it applied in one pass.
|
|
50
56
|
if (f.unvalidated) conf *= 0.85; // LLM validator unavailable
|
|
51
|
-
|
|
57
|
+
// f.llmOnly: currently unreachable — no producer anywhere in this
|
|
58
|
+
// codebase ever sets it (grepped). Documented as a no-op rather than
|
|
59
|
+
// silently deleted, since the intent ("LLM-only finding, no Layer-2
|
|
60
|
+
// path") is a real, plausible signal that just isn't wired yet.
|
|
61
|
+
if (f.llmOnly) conf *= 0.70;
|
|
52
62
|
}
|
|
53
63
|
conf = Math.max(0, Math.min(1, conf));
|
|
54
64
|
f.confidence = Math.round(conf * 1000) / 1000;
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
65
|
+
f.confidenceTier = _tierFor(f.confidence);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Premortem 3R-15: derive tier from the 2-decimal display value so a
|
|
70
|
+
// finding reported as "0.75" never lands in two tiers depending on the
|
|
71
|
+
// viewer's rounding (3-decimal raw 0.745 → 2-decimal 0.75 → high).
|
|
72
|
+
function _tierFor(confidence) {
|
|
73
|
+
const display = Math.round(confidence * 100) / 100;
|
|
74
|
+
if (display >= 0.75) return 'high';
|
|
75
|
+
if (display >= 0.50) return 'medium';
|
|
76
|
+
if (display >= 0.25) return 'low';
|
|
77
|
+
return 'very-low';
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Retroactively applies the unvalidated penalty to findings whose
|
|
81
|
+
// confidence was already computed by annotateConfidence BEFORE
|
|
82
|
+
// f.unvalidated could be known — annotateConfidence only computes from
|
|
83
|
+
// scratch when f.confidence is still null (so hand-tuned detector
|
|
84
|
+
// confidences survive untouched), and f.unvalidated is set by
|
|
85
|
+
// llm-validator/index.js, which runs well after the pipeline's first
|
|
86
|
+
// annotateConfidence pass. Call this once, immediately after LLM
|
|
87
|
+
// validation runs (or is skipped). Idempotent: a finding is only
|
|
88
|
+
// adjusted once, tracked via f._unvalidatedPenaltyApplied.
|
|
89
|
+
export function applyUnvalidatedPenalty(findings) {
|
|
90
|
+
if (!Array.isArray(findings)) return;
|
|
91
|
+
for (const f of findings) {
|
|
92
|
+
if (!f || typeof f !== 'object') continue;
|
|
93
|
+
if (f._unvalidatedPenaltyApplied) continue;
|
|
94
|
+
f._unvalidatedPenaltyApplied = true;
|
|
95
|
+
if (!f.unvalidated || typeof f.confidence !== 'number') continue;
|
|
96
|
+
f.confidence = Math.round(Math.max(0, Math.min(1, f.confidence * 0.85)) * 1000) / 1000;
|
|
97
|
+
f.confidenceTier = _tierFor(f.confidence);
|
|
64
98
|
}
|
|
65
99
|
}
|
|
@@ -296,8 +296,12 @@ function _stripState(dir) {
|
|
|
296
296
|
}
|
|
297
297
|
}
|
|
298
298
|
|
|
299
|
-
// `scoreCandidate` is deliberately NOT exported
|
|
300
|
-
//
|
|
301
|
-
//
|
|
302
|
-
//
|
|
303
|
-
|
|
299
|
+
// `scoreCandidate` is deliberately NOT exported — not even via _internals,
|
|
300
|
+
// the test-only-helpers convention used elsewhere in this codebase: an
|
|
301
|
+
// external caller could score a candidate and then write it by some other
|
|
302
|
+
// route, which is exactly the unscored-write path this module exists to
|
|
303
|
+
// make unavailable. Enrolment scores and writes as one operation or not at
|
|
304
|
+
// all. Keep it out of this object even though every other _internals
|
|
305
|
+
// export in this codebase is a harmless pure helper — this one specific
|
|
306
|
+
// function is the security boundary, not test plumbing.
|
|
307
|
+
export const _internals = { DEFAULT_TIER, _languageOf, _escapeRegex, _stripState };
|
|
@@ -54,6 +54,25 @@ function _matches(f, manifest, matcher) {
|
|
|
54
54
|
(manifest?.cwe ? f.cwe === manifest.cwe || matcher.test(f.cwe || '') : true);
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
/**
|
|
58
|
+
* Every finding, across every channel, that scores this entry.
|
|
59
|
+
*
|
|
60
|
+
* Third caller of the ONE predicate (gate, enrollment, and now the per-layer
|
|
61
|
+
* recall instrument in `bench/layer-recall/`). That instrument needs to know
|
|
62
|
+
* WHICH findings matched so it can attribute them to the analysis layer that
|
|
63
|
+
* produced them; re-deriving the predicate there is precisely the drift this
|
|
64
|
+
* module was extracted to prevent, so it reuses `_matches` instead.
|
|
65
|
+
*/
|
|
66
|
+
export function matchingFindings(scan, manifest, matcher = matcherFor(manifest)) {
|
|
67
|
+
const out = [];
|
|
68
|
+
for (const channel of CHANNELS) {
|
|
69
|
+
const arr = scan?.[channel];
|
|
70
|
+
if (!Array.isArray(arr)) continue;
|
|
71
|
+
for (const f of arr) if (_matches(f, manifest, matcher)) out.push(f);
|
|
72
|
+
}
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
|
|
57
76
|
/** Did the vulnerable (`pre/`) tree produce a matching finding? */
|
|
58
77
|
export function preHit(scan, manifest, matcher = matcherFor(manifest)) {
|
|
59
78
|
return _any(scan, f => _matches(f, manifest, matcher));
|
|
@@ -101,6 +101,49 @@ function isSanitizedExpr(text) {
|
|
|
101
101
|
return false;
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
+
// For each SANITIZER_PATTERNS match in `text`, find the nearest `(...)` call
|
|
105
|
+
// span after the match and return its [start, end) bounds. Used to tell
|
|
106
|
+
// "the sanitizer call actually wraps the tainted value" (HtmlEncode(x)) apart
|
|
107
|
+
// from "the sanitizer pattern matched something unrelated elsewhere in a
|
|
108
|
+
// compound expression" (comment + (IsNullOrEmpty(flag) ? ... : ...)) — a
|
|
109
|
+
// common .NET idiom that combines a tainted value with an unrelated validity
|
|
110
|
+
// check in the same expression.
|
|
111
|
+
function _sanitizerCallSpans(text) {
|
|
112
|
+
const spans = [];
|
|
113
|
+
for (const re of SANITIZER_PATTERNS) {
|
|
114
|
+
const g = new RegExp(re.source, re.flags.includes('g') ? re.flags : re.flags + 'g');
|
|
115
|
+
let m;
|
|
116
|
+
while ((m = g.exec(text))) {
|
|
117
|
+
const openIdx = text.indexOf('(', g.lastIndex - 1);
|
|
118
|
+
if (openIdx !== -1 && openIdx - g.lastIndex < 3) {
|
|
119
|
+
let depth = 1, j = openIdx + 1;
|
|
120
|
+
while (j < text.length && depth > 0) {
|
|
121
|
+
if (text[j] === '(') depth++;
|
|
122
|
+
else if (text[j] === ')') depth--;
|
|
123
|
+
j++;
|
|
124
|
+
}
|
|
125
|
+
spans.push([openIdx, j]);
|
|
126
|
+
}
|
|
127
|
+
if (g.lastIndex === m.index) g.lastIndex++; // avoid infinite loop on zero-width matches
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return spans;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Is `ref` tainted-and-NOT-neutralized by an enclosing sanitizer call in
|
|
134
|
+
// `text`? True when every occurrence of `ref` as a whole word sits outside
|
|
135
|
+
// every sanitizer call span (i.e. no sanitizer actually wraps it).
|
|
136
|
+
function _refEscapesSanitizers(text, ref, spans) {
|
|
137
|
+
if (!spans.length) return true;
|
|
138
|
+
const re = new RegExp(`\\b${ref.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'g');
|
|
139
|
+
let m, sawAny = false;
|
|
140
|
+
while ((m = re.exec(text))) {
|
|
141
|
+
sawAny = true;
|
|
142
|
+
if (!spans.some(([s, e]) => m.index >= s && m.index < e)) return true;
|
|
143
|
+
}
|
|
144
|
+
return !sawAny;
|
|
145
|
+
}
|
|
146
|
+
|
|
104
147
|
// Walk a single method's body and compute per-variable type + taint.
|
|
105
148
|
// Returns { typeMap, taintMap, sourceLines } where sourceLines records the
|
|
106
149
|
// declaration line at which each variable first became tainted.
|
|
@@ -296,12 +339,22 @@ export function receiverIsType(method, flow, receiver, typePattern) {
|
|
|
296
339
|
// for short expressions but unsafe for arbitrary string-containing text.
|
|
297
340
|
export function expressionIsTainted(flow, text, idents = null) {
|
|
298
341
|
if (!text && !idents) return false;
|
|
342
|
+
// Check known-tainted variable references that ESCAPE every sanitizer
|
|
343
|
+
// call span first — a whole-expression sanitizer-pattern match (below)
|
|
344
|
+
// must not clear a tainted variable the sanitizer doesn't actually wrap.
|
|
345
|
+
// `HtmlEncode(x)` genuinely neutralizes `x` (inside the call's parens);
|
|
346
|
+
// `comment + (string.IsNullOrEmpty(flag) ? ... : ...)` does not neutralize
|
|
347
|
+
// `comment` just because an unrelated sanitizer pattern matched `flag`
|
|
348
|
+
// elsewhere in the same compound expression.
|
|
349
|
+
const refs = idents || (text ? text.match(/\b[A-Za-z_]\w*\b/g) || [] : []);
|
|
350
|
+
const spans = text ? _sanitizerCallSpans(text) : [];
|
|
351
|
+
for (const r of refs) {
|
|
352
|
+
if (flow.taintMap.get(r) && (!text || _refEscapesSanitizers(text, r, spans))) return true;
|
|
353
|
+
}
|
|
299
354
|
if (text) {
|
|
300
355
|
if (isSourceExpr(text) && !isSanitizedExpr(text)) return true;
|
|
301
356
|
if (isSanitizedExpr(text)) return false;
|
|
302
357
|
}
|
|
303
|
-
const refs = idents || (text ? text.match(/\b[A-Za-z_]\w*\b/g) || [] : []);
|
|
304
|
-
for (const r of refs) if (flow.taintMap.get(r)) return true;
|
|
305
358
|
return false;
|
|
306
359
|
}
|
|
307
360
|
|
|
@@ -311,9 +364,15 @@ export function expressionIsTainted(flow, text, idents = null) {
|
|
|
311
364
|
// not treated as code identifiers.
|
|
312
365
|
export function argIsTainted(flow, arg) {
|
|
313
366
|
if (!arg) return false;
|
|
367
|
+
// Same span-aware fix as expressionIsTainted: a tainted identifier that
|
|
368
|
+
// escapes every sanitizer call span wins over a whole-argument
|
|
369
|
+
// sanitizer-pattern match.
|
|
370
|
+
const spans = arg.text ? _sanitizerCallSpans(arg.text) : [];
|
|
371
|
+
for (const id of arg.idents || []) {
|
|
372
|
+
if (flow.taintMap.get(id) && (!arg.text || _refEscapesSanitizers(arg.text, id, spans))) return true;
|
|
373
|
+
}
|
|
314
374
|
if (arg.text && isSanitizedExpr(arg.text)) return false;
|
|
315
375
|
if (arg.text && isSourceExpr(arg.text)) return true;
|
|
316
|
-
for (const id of arg.idents || []) if (flow.taintMap.get(id)) return true;
|
|
317
376
|
return false;
|
|
318
377
|
}
|
|
319
378
|
|
|
@@ -33,6 +33,7 @@ import * as yaml from '../util/yaml.js';
|
|
|
33
33
|
import { globFiles } from '../util/glob.js';
|
|
34
34
|
import { loadTrustedKeys, verifyRulePack } from './rule-pack-signing.js';
|
|
35
35
|
|
|
36
|
+
import { statePath, safeWriteState } from './state-dir.js';
|
|
36
37
|
const LANG_EXTS = {
|
|
37
38
|
javascript: ['.js', '.mjs', '.cjs', '.jsx'],
|
|
38
39
|
typescript: ['.ts', '.tsx'],
|
|
@@ -48,7 +49,7 @@ const LANG_EXTS = {
|
|
|
48
49
|
};
|
|
49
50
|
|
|
50
51
|
function rulesDir(scanRoot) {
|
|
51
|
-
return
|
|
52
|
+
return statePath(scanRoot, 'rules');
|
|
52
53
|
}
|
|
53
54
|
|
|
54
55
|
export function loadCustomRules(scanRoot) {
|
|
@@ -318,10 +319,11 @@ export function applyCustomRules(scanRoot, fileContents) {
|
|
|
318
319
|
}
|
|
319
320
|
if (shadow.length) {
|
|
320
321
|
try {
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
322
|
+
// Through the seam, so a read-only scan cannot leave shadow findings
|
|
323
|
+
// behind. Shadow rules are excluded from gates by design, which made this
|
|
324
|
+
// the least likely write for anyone to notice. (PRD M1)
|
|
325
|
+
safeWriteState(
|
|
326
|
+
statePath(scanRoot, 'shadow-findings.json'),
|
|
325
327
|
JSON.stringify({ generatedAt: new Date().toISOString(), findings: shadow }, null, 2),
|
|
326
328
|
);
|
|
327
329
|
} catch { /* non-fatal */ }
|
|
@@ -27,9 +27,9 @@
|
|
|
27
27
|
import * as fs from 'node:fs';
|
|
28
28
|
import * as path from 'node:path';
|
|
29
29
|
|
|
30
|
+
import { statePath, stateWritesEnabled } from './state-dir.js';
|
|
30
31
|
const OSV_API = 'https://api.osv.dev/v1/query';
|
|
31
|
-
|
|
32
|
-
const STATE_PATH = '.agentic-security/cve-alerts-state.json';
|
|
32
|
+
|
|
33
33
|
|
|
34
34
|
// Multi-ecosystem dep extraction. Each entry: { manifest filename → ecosystem }
|
|
35
35
|
const ECOSYSTEM_BY_MANIFEST = {
|
|
@@ -267,7 +267,7 @@ export async function _queryOsvForDep({ name, ecosystem }, { fetchImpl = globalT
|
|
|
267
267
|
// ─── State persistence ───────────────────────────────────────────────────
|
|
268
268
|
|
|
269
269
|
export function loadState(scanRoot) {
|
|
270
|
-
const fp =
|
|
270
|
+
const fp = statePath(scanRoot, 'cve-alerts-state.json');
|
|
271
271
|
if (!fs.existsSync(fp)) return { known: new Set(), lastRun: null };
|
|
272
272
|
try {
|
|
273
273
|
const j = JSON.parse(fs.readFileSync(fp, 'utf8'));
|
|
@@ -276,7 +276,8 @@ export function loadState(scanRoot) {
|
|
|
276
276
|
}
|
|
277
277
|
|
|
278
278
|
export function persistState(scanRoot, state) {
|
|
279
|
-
const fp =
|
|
279
|
+
const fp = statePath(scanRoot, 'cve-alerts-state.json');
|
|
280
|
+
if (!stateWritesEnabled()) return;
|
|
280
281
|
fs.mkdirSync(path.dirname(fp), { recursive: true });
|
|
281
282
|
fs.writeFileSync(fp, JSON.stringify({
|
|
282
283
|
known: [...state.known].sort(),
|
|
@@ -285,7 +286,7 @@ export function persistState(scanRoot, state) {
|
|
|
285
286
|
}
|
|
286
287
|
|
|
287
288
|
export function loadConfig(scanRoot) {
|
|
288
|
-
const fp =
|
|
289
|
+
const fp = statePath(scanRoot, 'cve-alerts.json');
|
|
289
290
|
if (!fs.existsSync(fp)) return null;
|
|
290
291
|
try { return JSON.parse(fs.readFileSync(fp, 'utf8')); } catch { return null; }
|
|
291
292
|
}
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
import * as fs from 'node:fs';
|
|
20
20
|
import * as path from 'node:path';
|
|
21
21
|
|
|
22
|
+
import { statePath } from './state-dir.js';
|
|
22
23
|
const CACHE = path.join(process.env.HOME || '/tmp', '.claude', 'agentic-security', 'osv-cache');
|
|
23
24
|
const TYPOSQUAT_LEVENSHTEIN = 2;
|
|
24
25
|
const NEW_PACKAGE_WINDOW_DAYS = 7;
|
|
@@ -61,7 +62,7 @@ function _loadPopular(ecosystem) {
|
|
|
61
62
|
}
|
|
62
63
|
|
|
63
64
|
function _loadPolicy(scanRoot) {
|
|
64
|
-
const fp =
|
|
65
|
+
const fp = statePath(scanRoot, 'sca-policy.yml');
|
|
65
66
|
if (!fs.existsSync(fp)) return { deny: [] };
|
|
66
67
|
try {
|
|
67
68
|
const body = fs.readFileSync(fp, 'utf8');
|
|
@@ -4,7 +4,10 @@
|
|
|
4
4
|
// returns platform-specific security findings: missing headers, public previews,
|
|
5
5
|
// no health checks, unsafe infra settings.
|
|
6
6
|
//
|
|
7
|
-
// Platforms: Vercel, Railway, Fly.io,
|
|
7
|
+
// Platforms: Vercel, Railway, Fly.io, Netlify, Cloudflare.
|
|
8
|
+
// NOT implemented despite being previously listed here: Render, AWS Amplify
|
|
9
|
+
// (found via Stage-0 doc audit, 2026 — grepped for any render/amplify config
|
|
10
|
+
// path or platform key in this file; zero hits for either).
|
|
8
11
|
|
|
9
12
|
import * as fs from 'node:fs';
|
|
10
13
|
import * as path from 'node:path';
|
|
@@ -16,6 +16,7 @@ import * as path from 'node:path';
|
|
|
16
16
|
import * as crypto from 'node:crypto';
|
|
17
17
|
import { PACKS } from './rule-packs.js';
|
|
18
18
|
|
|
19
|
+
import { stateDir, statePath } from './state-dir.js';
|
|
19
20
|
export const SCANNER_VERSION = '0.39.2';
|
|
20
21
|
const LOCK_FILE = 'rules.lock.json';
|
|
21
22
|
|
|
@@ -40,7 +41,7 @@ export function buildLockfile() {
|
|
|
40
41
|
}
|
|
41
42
|
|
|
42
43
|
export function writeLockfile(scanRoot) {
|
|
43
|
-
const dir =
|
|
44
|
+
const dir = stateDir(scanRoot);
|
|
44
45
|
fs.mkdirSync(dir, { recursive: true });
|
|
45
46
|
const fp = path.join(dir, LOCK_FILE);
|
|
46
47
|
const lock = buildLockfile();
|
|
@@ -49,7 +50,7 @@ export function writeLockfile(scanRoot) {
|
|
|
49
50
|
}
|
|
50
51
|
|
|
51
52
|
export function readLockfile(scanRoot) {
|
|
52
|
-
const fp =
|
|
53
|
+
const fp = statePath(scanRoot, LOCK_FILE);
|
|
53
54
|
if (!fs.existsSync(fp)) return null;
|
|
54
55
|
try { return JSON.parse(fs.readFileSync(fp, 'utf8')); } catch { return null; }
|
|
55
56
|
}
|
package/src/posture/drift.js
CHANGED
|
@@ -8,7 +8,13 @@
|
|
|
8
8
|
|
|
9
9
|
function _routeKey(r) { return `${r.method || 'ANY'} ${r.path || '(file)'} @ ${r.file}:${r.line}`; }
|
|
10
10
|
function _depKey(c) { return `${c.ecosystem}:${c.name}@${c.version}`; }
|
|
11
|
-
|
|
11
|
+
// Prefer stableId (posture/stable-id.js) — it omits the exact source line by
|
|
12
|
+
// design, so an unrelated edit that shifts a still-unfixed finding's line
|
|
13
|
+
// number doesn't register as one "removed" + one "added" finding, inflating
|
|
14
|
+
// drift tier and falsely flagging a PR as introducing/fixing something it
|
|
15
|
+
// didn't touch. Falls back to the line-sensitive key only for finding shapes
|
|
16
|
+
// that never got a stableId annotated.
|
|
17
|
+
function _findingKey(f) { return f.stableId || `${f.kind}:${f.file}:${f.line}:${(f.vuln||'').slice(0,80)}`; }
|
|
12
18
|
|
|
13
19
|
function _toMap(arr, keyFn) {
|
|
14
20
|
const m = new Map();
|
package/src/posture/epss.js
CHANGED
|
@@ -6,10 +6,19 @@
|
|
|
6
6
|
// from those attackers are actively weaponizing.
|
|
7
7
|
//
|
|
8
8
|
// Decoration shape (added to each SCA finding with a CVE):
|
|
9
|
-
//
|
|
9
|
+
// epssScore: 0.92345 (this comment previously said `epss:`; the code has
|
|
10
|
+
// always written `epssScore` — the comment was wrong,
|
|
11
|
+
// not the code)
|
|
10
12
|
// epssPercentile: 0.987
|
|
11
13
|
// exploitedNow: true ← percentile >= 0.95
|
|
12
14
|
//
|
|
15
|
+
// ⚠ engine.js ALSO sets f.epssScore/f.epssPercentile directly (around line
|
|
16
|
+
// 6451), independently of this module's fetchEPSS/decorate path — two
|
|
17
|
+
// implementations of the same decoration. This module's own `fetchEPSS` has
|
|
18
|
+
// no in-tree caller (allowlisted in no-dead-modules.test.js as future-public
|
|
19
|
+
// API); the live path is the one in engine.js. Worth consolidating, not
|
|
20
|
+
// resolved here.
|
|
21
|
+
//
|
|
13
22
|
// Source: https://api.first.org/data/v1/epss?cve=CVE-...,CVE-...
|
|
14
23
|
// Cached on disk: ~/.claude/agentic-security/epss-cache/<sha256>.json
|
|
15
24
|
// 24-hour TTL. Falls back gracefully when offline.
|
|
@@ -93,6 +102,9 @@ function cvesIn(finding) {
|
|
|
93
102
|
const found = new Set();
|
|
94
103
|
if (typeof finding.cve === 'string') found.add(finding.cve.toUpperCase());
|
|
95
104
|
if (Array.isArray(finding.cves)) for (const c of finding.cves) found.add(String(c).toUpperCase());
|
|
105
|
+
// The actual field name every SCA finding in this codebase uses (src/sca/
|
|
106
|
+
// CLAUDE.md's documented shape; carried through by normalizeFindings).
|
|
107
|
+
if (Array.isArray(finding.cveAliases)) for (const c of finding.cveAliases) found.add(String(c).toUpperCase());
|
|
96
108
|
if (Array.isArray(finding.vulnerabilities)) {
|
|
97
109
|
for (const v of finding.vulnerabilities) {
|
|
98
110
|
if (typeof v.id === 'string' && v.id.startsWith('CVE-')) found.add(v.id.toUpperCase());
|