@clear-capabilities/agentic-security-scanner 0.143.0 → 0.144.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 +304 -0
- package/bin/agentic-security.js +477 -47
- package/dist/1.index.js +223 -0
- package/dist/113.index.js +108 -17
- package/dist/144.index.js +163 -0
- package/dist/178.index.js +1 -1
- package/dist/238.index.js +3 -2
- package/dist/265.index.js +191 -0
- package/dist/384.index.js +1 -1
- package/dist/435.index.js +165 -52
- package/dist/526.index.js +108 -17
- package/dist/552.index.js +97 -0
- package/dist/637.index.js +1 -1
- package/dist/730.index.js +311 -0
- package/dist/736.index.js +301 -0
- package/dist/824.index.js +7 -0
- package/dist/905.index.js +88 -22
- package/dist/920.index.js +491 -0
- package/dist/970.index.js +109 -0
- package/dist/agentic-security.mjs +13 -13
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/dist/calibration-seed.json +2 -0
- package/package.json +19 -11
- package/src/dataflow/index.js +18 -0
- package/src/dataflow/privacy-catalog.js +290 -0
- package/src/dataflow/privacy-deep-walker.js +515 -0
- package/src/dataflow/privacy-governance.js +126 -0
- package/src/dataflow/privacy-inventory.js +154 -0
- package/src/dataflow/privacy-sink-policy.js +125 -0
- package/src/dataflow/privacy-taint.js +115 -54
- package/src/dataflow/privacy-taxonomy.js +233 -0
- package/src/discovery/disprove.js +7 -3
- package/src/discovery/hunter.js +9 -5
- package/src/discovery/index.js +2 -2
- package/src/discovery/llm-invoke.js +69 -13
- package/src/egress/audit.js +147 -0
- package/src/egress/policy.js +313 -0
- package/src/egress/redact.js +180 -0
- package/src/engine.js +575 -288
- package/src/fix/apply-fix-service.js +403 -0
- package/src/fix/approver-registry.js +157 -0
- package/src/llm-validator/index.js +86 -9
- package/src/llm-validator/model-status.js +66 -0
- package/src/mcp/tools.js +157 -50
- package/src/pipeline/analyzer-supervisor.js +93 -0
- package/src/pipeline/analyzer-worker.js +26 -0
- package/src/pipeline/annotator-runner.js +33 -0
- package/src/pipeline/assurance-mode.js +91 -0
- package/src/pipeline/cascade-worker-pool.js +172 -0
- package/src/pipeline/cascade-worker.js +43 -0
- package/src/pipeline/coverage-ledger.js +0 -0
- package/src/pipeline/detector-runner.js +51 -0
- package/src/pipeline/enrichment-completion.js +58 -0
- package/src/pipeline/evidence-provenance.js +91 -0
- package/src/pipeline/finding-schema.js +101 -0
- package/src/pipeline/legacy-compat.js +101 -0
- package/src/pipeline/producer-collector.js +48 -0
- package/src/pipeline/producer-registry.js +112 -0
- package/src/pipeline/scan-health.js +144 -0
- package/src/posture/CLAUDE.md +2 -0
- package/src/posture/accuracy-scorecard.js +96 -1
- package/src/posture/adversary-agent.js +15 -3
- package/src/posture/artifact-registry.js +217 -0
- package/src/posture/auditor-walkthrough.js +70 -8
- package/src/posture/calibration-feedback.js +201 -0
- package/src/posture/calibration-seed.json +2 -0
- package/src/posture/calibration.js +25 -0
- package/src/posture/compliance-evidence-signing.js +131 -0
- package/src/posture/compliance-policy.js +314 -17
- package/src/posture/custom-rules.js +36 -0
- package/src/posture/deterministic.js +8 -1
- package/src/posture/encryption-provider.js +205 -0
- package/src/posture/evidence-grade-wording.js +71 -0
- package/src/posture/fix-history.js +113 -19
- package/src/posture/fix-honesty-gate.js +47 -6
- package/src/posture/fix-verify.js +56 -7
- package/src/posture/fleet.js +0 -0
- package/src/posture/flow-narration.js +7 -2
- package/src/posture/legal-hold.js +140 -0
- package/src/posture/llm-redteam.js +10 -1
- package/src/posture/material-change.js +90 -0
- package/src/posture/policy-bundle.js +274 -0
- package/src/posture/privacy-framework.js +33 -6
- package/src/posture/production-feedback.js +179 -0
- package/src/posture/retention-policy.js +132 -0
- package/src/posture/risk-dollars.js +216 -26
- package/src/posture/scan-checkpoint.js +176 -31
- package/src/posture/state-dir.js +36 -1
- package/src/posture/state-lifecycle-report.js +77 -0
- package/src/posture/suppressions.js +59 -3
- package/src/privacy/ir-adapter.js +380 -0
- package/src/report/index.js +51 -2
- package/src/report/oscal.js +7 -2
- package/src/sast/cpp.js +3 -14
- package/src/sca/llm-function-extract.js +6 -0
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
// Central egress policy (assurance-hardening PRD FR-601).
|
|
2
|
+
//
|
|
3
|
+
// Every outbound call this codebase makes to an LLM provider must be
|
|
4
|
+
// evaluated HERE before its prompt is constructed and before any network
|
|
5
|
+
// client touches the endpoint. A denied call must produce no network
|
|
6
|
+
// request and a machine-readable decision object — never a silent no-op
|
|
7
|
+
// and never a request that fires anyway with the decision merely logged
|
|
8
|
+
// after the fact.
|
|
9
|
+
//
|
|
10
|
+
// SCOPE (this is FR-601 only, not the fuller FR-602-607 cluster):
|
|
11
|
+
// - A real allow/deny decision, evaluated before prompt construction.
|
|
12
|
+
// - `mode: deny` / `mode: local-only` / provider allow-deny lists as the
|
|
13
|
+
// concrete denial conditions this cycle implements, because FR-601's
|
|
14
|
+
// acceptance criterion ("a denied call results in no network request")
|
|
15
|
+
// requires at least one genuine, testable deny path to exist.
|
|
16
|
+
// - Deliberately NOT in scope here: redaction of the outbound payload
|
|
17
|
+
// (FR-603, already partially done by llm-validator/redact.js for its
|
|
18
|
+
// own path only), and persistent per-call audit records (FR-604). Each
|
|
19
|
+
// remains separate, tracked future work.
|
|
20
|
+
// - FR-602 (provider/model/role/region/repository/path/data-class/
|
|
21
|
+
// max-context constraints) IS in scope as of the FR-602 cycle — see the
|
|
22
|
+
// dimension loop and the path/token checks in evaluateEgress below.
|
|
23
|
+
// Each new dimension is evaluated ONLY when the caller supplies the
|
|
24
|
+
// corresponding ctx value AND the config restricts that dimension —
|
|
25
|
+
// a caller with no opinion on, say, region is never blocked by a
|
|
26
|
+
// region rule it has no way to satisfy or violate. This mirrors the
|
|
27
|
+
// provider dimension's own pre-existing shape (deniedX/allowedX list
|
|
28
|
+
// membership) for model/role/region/repository/data-class; path uses
|
|
29
|
+
// glob matching (util/glob.js's matchesAnyGlob, already used elsewhere
|
|
30
|
+
// in this codebase for ignore-pattern matching) since a single literal
|
|
31
|
+
// string is the wrong shape for a file path constraint; max-context
|
|
32
|
+
// is a numeric cap on an ESTIMATED token count the caller computes
|
|
33
|
+
// itself (evaluateEgress runs before prompt construction, so it has
|
|
34
|
+
// no prompt to measure — the caller must estimate from what it is
|
|
35
|
+
// about to build).
|
|
36
|
+
// - FR-605 (unambiguous local-only mode, anti-URL-smuggling) IS in scope
|
|
37
|
+
// as of the FR-605 cycle: `mode: local-only` here calls the same
|
|
38
|
+
// literal-only `isLoopbackUrl` check `local-endpoint.js` uses for its
|
|
39
|
+
// own preset, and every real HTTP-calling caller in the codebase
|
|
40
|
+
// (llm-validator, discovery/llm-invoke's single- AND consensus-endpoint
|
|
41
|
+
// paths, adversary-agent's own default path, flow-narration,
|
|
42
|
+
// sca/llm-function-extract) routes through this function before
|
|
43
|
+
// dialing out — verified by tracing every reference to
|
|
44
|
+
// AGENTIC_SECURITY_LLM_ENDPOINT in the codebase, not assumed. The one
|
|
45
|
+
// real gap found and closed in that pass: discovery/llm-invoke.js's
|
|
46
|
+
// multi-endpoint consensus mode used to construct its caller with NO
|
|
47
|
+
// egress check at all, so a `mode: local-only` policy was silently
|
|
48
|
+
// bypassable by setting AGENTIC_SECURITY_LLM_ENDPOINTS instead of the
|
|
49
|
+
// single-endpoint var. Fixed there, not here — see that file's own
|
|
50
|
+
// comment on `makeConsensusInvoke`.
|
|
51
|
+
// - FR-607 (approved-provider metadata for regulated profiles) IS in
|
|
52
|
+
// scope as of the FR-607 cycle: `regulatedProfile.requireApprovedProviders`
|
|
53
|
+
// is a no-op unless configured; once set, every provider must have a
|
|
54
|
+
// matching `approvedProviders[provider]` entry carrying a non-empty
|
|
55
|
+
// value for each required attribute (default: dpaStatus, baaStatus,
|
|
56
|
+
// retentionPolicy) or the call is denied. Organizational/contractual
|
|
57
|
+
// facts, never inferred — same discipline
|
|
58
|
+
// dataflow/privacy-governance.js's GOVERNANCE_FIELDS already
|
|
59
|
+
// established for RoPA/DPIA.
|
|
60
|
+
//
|
|
61
|
+
// DEFAULT BEHAVIOR. No config file and no env override => 'allow'. This
|
|
62
|
+
// preserves the existing default-on UX for every LLM-backed feature in this
|
|
63
|
+
// codebase (discovery, validation, adversary-agent, etc.) — FR-601 asks for
|
|
64
|
+
// a policy DECISION on every call, not a policy that silently disables
|
|
65
|
+
// functionality nobody configured it to disable.
|
|
66
|
+
|
|
67
|
+
import * as fs from 'node:fs';
|
|
68
|
+
import { load as loadYaml } from '../util/yaml.js';
|
|
69
|
+
import { statePath } from '../posture/state-dir.js';
|
|
70
|
+
import { isLoopbackUrl } from '../llm-validator/local-endpoint.js';
|
|
71
|
+
import { matchesAnyGlob } from '../util/glob.js';
|
|
72
|
+
|
|
73
|
+
// FR-602: the list-membership dimensions, keyed by ctx field name to the
|
|
74
|
+
// config's allow/deny list PAIR name. `provider` (FR-601) already used
|
|
75
|
+
// this exact shape as allowedProviders/deniedProviders; the rest are new.
|
|
76
|
+
const LIST_DIMENSIONS = [
|
|
77
|
+
{ ctxKey: 'model', allowKey: 'allowedModels', denyKey: 'deniedModels' },
|
|
78
|
+
{ ctxKey: 'role', allowKey: 'allowedRoles', denyKey: 'deniedRoles' },
|
|
79
|
+
{ ctxKey: 'region', allowKey: 'allowedRegions', denyKey: 'deniedRegions' },
|
|
80
|
+
{ ctxKey: 'repository', allowKey: 'allowedRepositories', denyKey: 'deniedRepositories' },
|
|
81
|
+
{ ctxKey: 'dataClass', allowKey: 'allowedDataClasses', denyKey: 'deniedDataClasses' },
|
|
82
|
+
];
|
|
83
|
+
|
|
84
|
+
const POLICY_FILE = 'egress-policy.yml';
|
|
85
|
+
|
|
86
|
+
// FR-607: when a regulated profile requires approved-provider metadata, an
|
|
87
|
+
// approvedProviders[provider] entry must carry a non-empty value for each
|
|
88
|
+
// of these attributes unless the operator names a different set via
|
|
89
|
+
// regulatedProfile.requiredAttributes. DPA (Data Processing Agreement) and
|
|
90
|
+
// BAA (Business Associate Agreement, HIPAA) are organizational/contractual
|
|
91
|
+
// facts — not derivable from code, same "state it, never infer it"
|
|
92
|
+
// discipline dataflow/privacy-governance.js's GOVERNANCE_FIELDS already
|
|
93
|
+
// established for RoPA/DPIA fields.
|
|
94
|
+
const DEFAULT_REQUIRED_PROVIDER_ATTRIBUTES = ['dpaStatus', 'baaStatus', 'retentionPolicy'];
|
|
95
|
+
|
|
96
|
+
// FR-603 (egress/redact.js) reads the same egress-policy.yml file for its
|
|
97
|
+
// own keys (proprietaryPaths, customerDataPatterns, redactPii) — one
|
|
98
|
+
// operator-facing config surface for the whole egress epic rather than a
|
|
99
|
+
// second file. Exported (not `_`-prefixed like the rest of this module's
|
|
100
|
+
// internals) for that reuse; still not part of the public evaluateEgress
|
|
101
|
+
// API surface.
|
|
102
|
+
export function loadPolicyConfig(scanRoot) {
|
|
103
|
+
let fp;
|
|
104
|
+
try { fp = statePath(scanRoot, POLICY_FILE); } catch { return null; }
|
|
105
|
+
// Read first, check second — an existsSync-then-readFileSync pair is a
|
|
106
|
+
// check-then-use race (the file can vanish between the two calls); this
|
|
107
|
+
// codebase's convention (apply-fix-service.js's readVerifiedScan) is to
|
|
108
|
+
// let the read fail and classify ENOENT rather than pre-check existence.
|
|
109
|
+
let raw;
|
|
110
|
+
try {
|
|
111
|
+
raw = fs.readFileSync(fp, 'utf8');
|
|
112
|
+
} catch (e) {
|
|
113
|
+
return null; // ENOENT (no config file) or any other read failure — same fallback either way
|
|
114
|
+
}
|
|
115
|
+
try {
|
|
116
|
+
const doc = loadYaml(raw);
|
|
117
|
+
return (doc && typeof doc === 'object' && !Array.isArray(doc)) ? doc : null;
|
|
118
|
+
} catch { return null; }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Best-effort, purely informational provider label from the endpoint host. */
|
|
122
|
+
function _providerOf(endpoint) {
|
|
123
|
+
let u;
|
|
124
|
+
try { u = new URL(String(endpoint)); } catch { return 'unknown'; }
|
|
125
|
+
if (isLoopbackUrl(endpoint)) return 'local';
|
|
126
|
+
const h = u.hostname.toLowerCase();
|
|
127
|
+
if (h.includes('anthropic')) return 'anthropic';
|
|
128
|
+
if (h.includes('openai')) return 'openai';
|
|
129
|
+
if (h.includes('googleapis') || h.includes('generativelanguage')) return 'google';
|
|
130
|
+
return h || 'unknown';
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Evaluate whether an outbound LLM call is permitted. Call sites MUST check
|
|
135
|
+
* `.allowed` before building the prompt for that call and before invoking
|
|
136
|
+
* any fetch/HTTP client against `ctx.endpoint`.
|
|
137
|
+
*
|
|
138
|
+
* @param {object} ctx
|
|
139
|
+
* @param {string} [ctx.scanRoot] - project root, for reading
|
|
140
|
+
* `.agentic-security/egress-policy.yml`; a missing/unreadable root is
|
|
141
|
+
* treated the same as no config file (falls back to 'allow').
|
|
142
|
+
* @param {string} ctx.purpose - short, stable id of the call site (e.g.
|
|
143
|
+
* 'discovery-hunter', 'llm-validator', 'adversary-agent') — never
|
|
144
|
+
* free text, so a decision is comparable across runs and never carries
|
|
145
|
+
* source content.
|
|
146
|
+
* @param {string} ctx.endpoint - the URL that would be called. Required —
|
|
147
|
+
* a caller with no configured endpoint should never reach this function
|
|
148
|
+
* at all (every call site already has its own pre-existing "is anything
|
|
149
|
+
* configured" check upstream of prompt construction).
|
|
150
|
+
* @param {string} [ctx.model] - FR-602: the model id the caller intends to
|
|
151
|
+
* use, if known. Evaluated against allowedModels/deniedModels only when
|
|
152
|
+
* supplied.
|
|
153
|
+
* @param {string} [ctx.role] - FR-602: which ROLE this call serves (the
|
|
154
|
+
* same closed vocabulary llm-validator/providers.js's ROLES uses, but
|
|
155
|
+
* this function does not require membership in it — a caller in a
|
|
156
|
+
* different subsystem may have its own role names). Evaluated against
|
|
157
|
+
* allowedRoles/deniedRoles only when supplied.
|
|
158
|
+
* @param {string} [ctx.region] - FR-602: a data-residency/provider region
|
|
159
|
+
* identifier, if the caller knows one. This codebase does not derive a
|
|
160
|
+
* region for any provider today; it is purely caller-supplied and
|
|
161
|
+
* evaluated against allowedRegions/deniedRegions only when supplied.
|
|
162
|
+
* @param {string} [ctx.repository] - FR-602: a repository identifier for
|
|
163
|
+
* the current scan (e.g. a git remote or a stable project label), if the
|
|
164
|
+
* caller knows one. Evaluated against allowedRepositories/
|
|
165
|
+
* deniedRepositories only when supplied.
|
|
166
|
+
* @param {string} [ctx.path] - FR-602: a '/'-separated relative path
|
|
167
|
+
* representative of the content in this call (e.g. the finding's source
|
|
168
|
+
* file), if applicable. Evaluated by glob against allowedPaths/
|
|
169
|
+
* deniedPaths only when supplied.
|
|
170
|
+
* @param {string} [ctx.dataClass] - FR-602: a regulated-data-class label
|
|
171
|
+
* (e.g. from dataflow/privacy-taxonomy.js's taxonomy) present in the
|
|
172
|
+
* content this call would send, if the caller has classified it.
|
|
173
|
+
* Evaluated against allowedDataClasses/deniedDataClasses only when
|
|
174
|
+
* supplied.
|
|
175
|
+
* @param {number} [ctx.contextTokens] - FR-602: the caller's OWN estimate
|
|
176
|
+
* of the prompt's token size (this function runs before prompt
|
|
177
|
+
* construction, so it cannot measure one itself). Compared against
|
|
178
|
+
* `maxContextTokens` in config only when both are present.
|
|
179
|
+
* @returns {{allowed:boolean, decision:'allow'|'deny', reason:string|null,
|
|
180
|
+
* provider:string, policySource:'default'|'env'|'config', purpose:string}}
|
|
181
|
+
* Sanitized — carries no prompt/source content, only metadata, so it is
|
|
182
|
+
* always safe to log or attach to a report as-is (PRD walkthrough
|
|
183
|
+
* scenario 7: "records a sanitized decision without retaining source").
|
|
184
|
+
*/
|
|
185
|
+
export function evaluateEgress(ctx = {}) {
|
|
186
|
+
const {
|
|
187
|
+
scanRoot, purpose = 'unknown', endpoint,
|
|
188
|
+
model = null, role = null, region = null, repository = null,
|
|
189
|
+
path: filePath = null, dataClass = null, contextTokens = null,
|
|
190
|
+
} = ctx;
|
|
191
|
+
|
|
192
|
+
if (!endpoint || typeof endpoint !== 'string') {
|
|
193
|
+
return { allowed: false, decision: 'deny', reason: 'no endpoint provided to evaluateEgress', provider: 'unknown', policySource: 'default', purpose };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const provider = _providerOf(endpoint);
|
|
197
|
+
|
|
198
|
+
// Blunt, ops-friendly kill switch — same shape as the existing
|
|
199
|
+
// AGENTIC_SECURITY_LLM_VALIDATE=0 precedent in llm-validator/index.js.
|
|
200
|
+
if (process.env.AGENTIC_SECURITY_EGRESS_DENY === '1') {
|
|
201
|
+
return { allowed: false, decision: 'deny', reason: "AGENTIC_SECURITY_EGRESS_DENY=1 is set", provider, policySource: 'env', purpose };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const envMode = process.env.AGENTIC_SECURITY_EGRESS_MODE;
|
|
205
|
+
const cfg = loadPolicyConfig(scanRoot);
|
|
206
|
+
const mode = envMode || (cfg && cfg.mode) || 'allow';
|
|
207
|
+
const policySource = envMode ? 'env' : ((cfg && cfg.mode) ? 'config' : 'default');
|
|
208
|
+
|
|
209
|
+
if (mode === 'deny') {
|
|
210
|
+
return { allowed: false, decision: 'deny', reason: "egress mode is 'deny'", provider, policySource, purpose };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (mode === 'local-only' && !isLoopbackUrl(endpoint)) {
|
|
214
|
+
return {
|
|
215
|
+
allowed: false, decision: 'deny',
|
|
216
|
+
reason: `egress mode is 'local-only' and the endpoint is not a loopback address`,
|
|
217
|
+
provider, policySource, purpose,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const deniedProviders = (cfg && Array.isArray(cfg.deniedProviders)) ? cfg.deniedProviders : [];
|
|
222
|
+
if (deniedProviders.includes(provider)) {
|
|
223
|
+
return { allowed: false, decision: 'deny', reason: `provider '${provider}' is in deniedProviders`, provider, policySource: 'config', purpose };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const allowedProviders = (cfg && Array.isArray(cfg.allowedProviders)) ? cfg.allowedProviders : null;
|
|
227
|
+
if (allowedProviders && !allowedProviders.includes(provider)) {
|
|
228
|
+
return { allowed: false, decision: 'deny', reason: `provider '${provider}' is not in allowedProviders`, provider, policySource: 'config', purpose };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// FR-602: the remaining list-membership dimensions (model/role/region/
|
|
232
|
+
// repository/dataClass), each a no-op unless BOTH the caller supplied a
|
|
233
|
+
// value AND the config restricts that specific dimension.
|
|
234
|
+
const ctxValues = { model, role, region, repository, dataClass };
|
|
235
|
+
for (const { ctxKey, allowKey, denyKey } of LIST_DIMENSIONS) {
|
|
236
|
+
const value = ctxValues[ctxKey];
|
|
237
|
+
if (value == null) continue;
|
|
238
|
+
const deniedList = (cfg && Array.isArray(cfg[denyKey])) ? cfg[denyKey] : [];
|
|
239
|
+
if (deniedList.includes(value)) {
|
|
240
|
+
return { allowed: false, decision: 'deny', reason: `${ctxKey} '${value}' is in ${denyKey}`, provider, policySource: 'config', purpose };
|
|
241
|
+
}
|
|
242
|
+
const allowedList = (cfg && Array.isArray(cfg[allowKey])) ? cfg[allowKey] : null;
|
|
243
|
+
if (allowedList && !allowedList.includes(value)) {
|
|
244
|
+
return { allowed: false, decision: 'deny', reason: `${ctxKey} '${value}' is not in ${allowKey}`, provider, policySource: 'config', purpose };
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// FR-602: path is glob-matched, not list-membership — a single literal
|
|
249
|
+
// string is the wrong shape for a file path constraint.
|
|
250
|
+
if (filePath != null) {
|
|
251
|
+
const deniedPaths = (cfg && Array.isArray(cfg.deniedPaths)) ? cfg.deniedPaths : [];
|
|
252
|
+
if (matchesAnyGlob(filePath, deniedPaths)) {
|
|
253
|
+
return { allowed: false, decision: 'deny', reason: `path '${filePath}' matches a deniedPaths pattern`, provider, policySource: 'config', purpose };
|
|
254
|
+
}
|
|
255
|
+
const allowedPaths = (cfg && Array.isArray(cfg.allowedPaths)) ? cfg.allowedPaths : null;
|
|
256
|
+
if (allowedPaths && !matchesAnyGlob(filePath, allowedPaths)) {
|
|
257
|
+
return { allowed: false, decision: 'deny', reason: `path '${filePath}' does not match any allowedPaths pattern`, provider, policySource: 'config', purpose };
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// FR-602: max-context is a numeric cap, evaluated only when the caller
|
|
262
|
+
// supplied its own token estimate AND the config sets a cap.
|
|
263
|
+
if (typeof contextTokens === 'number' && cfg && typeof cfg.maxContextTokens === 'number') {
|
|
264
|
+
if (contextTokens > cfg.maxContextTokens) {
|
|
265
|
+
return {
|
|
266
|
+
allowed: false, decision: 'deny',
|
|
267
|
+
reason: `estimated context (${contextTokens} tokens) exceeds maxContextTokens (${cfg.maxContextTokens})`,
|
|
268
|
+
provider, policySource: 'config', purpose,
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// FR-607: a regulated profile can require APPROVED-PROVIDER metadata
|
|
274
|
+
// (DPA/BAA status, retention policy) before a provider is usable at all —
|
|
275
|
+
// a no-op unless the operator opts in via regulatedProfile.requireApproved
|
|
276
|
+
// Providers, same "restricts nothing until configured" default every
|
|
277
|
+
// other dimension above follows.
|
|
278
|
+
const regulatedProfile = (cfg && typeof cfg.regulatedProfile === 'object' && cfg.regulatedProfile) ? cfg.regulatedProfile : null;
|
|
279
|
+
let approvedProviderMetadata = null;
|
|
280
|
+
if (regulatedProfile && regulatedProfile.requireApprovedProviders) {
|
|
281
|
+
const requiredAttributes = (Array.isArray(regulatedProfile.requiredAttributes) && regulatedProfile.requiredAttributes.length)
|
|
282
|
+
? regulatedProfile.requiredAttributes
|
|
283
|
+
: DEFAULT_REQUIRED_PROVIDER_ATTRIBUTES;
|
|
284
|
+
const approvedProviders = (cfg && typeof cfg.approvedProviders === 'object' && cfg.approvedProviders) ? cfg.approvedProviders : {};
|
|
285
|
+
const entry = approvedProviders[provider];
|
|
286
|
+
if (!entry || typeof entry !== 'object') {
|
|
287
|
+
return {
|
|
288
|
+
allowed: false, decision: 'deny',
|
|
289
|
+
reason: `provider '${provider}' has no approved-provider metadata configured, and this profile requires one (regulatedProfile.requireApprovedProviders)`,
|
|
290
|
+
provider, policySource: 'config', purpose,
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
const missing = requiredAttributes.filter((attr) => {
|
|
294
|
+
const v = entry[attr];
|
|
295
|
+
return !(typeof v === 'string' && v.length > 0 && v !== 'none' && v !== 'not_signed');
|
|
296
|
+
});
|
|
297
|
+
if (missing.length) {
|
|
298
|
+
return {
|
|
299
|
+
allowed: false, decision: 'deny',
|
|
300
|
+
reason: `provider '${provider}' is missing required approved-provider attribute(s): ${missing.join(', ')}`,
|
|
301
|
+
provider, policySource: 'config', purpose,
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
approvedProviderMetadata = entry;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
return {
|
|
308
|
+
allowed: true, decision: 'allow', reason: null, provider, policySource, purpose,
|
|
309
|
+
...(approvedProviderMetadata ? { approvedProviderMetadata } : {}),
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
export const _internals = { loadPolicyConfig, _providerOf, POLICY_FILE, DEFAULT_REQUIRED_PROVIDER_ATTRIBUTES };
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// Outbound-payload content redaction (assurance-hardening PRD FR-603).
|
|
2
|
+
//
|
|
3
|
+
// policy.js decides WHETHER a call is allowed to leave the machine at all.
|
|
4
|
+
// This module decides WHAT is allowed to be IN the payload once a call is
|
|
5
|
+
// already permitted — the content-level companion to that call-level gate.
|
|
6
|
+
// Both read the same operator config (egress-policy.yml) via
|
|
7
|
+
// policy.js's exported loadPolicyConfig, so there is one config surface
|
|
8
|
+
// for the whole egress epic rather than a second file for redaction.
|
|
9
|
+
//
|
|
10
|
+
// Four categories, applied in a fixed order so an earlier, more specific
|
|
11
|
+
// pass never fights a later, more general one over the same span (mirrors
|
|
12
|
+
// llm-validator/redact.js's own most-specific-first rationale):
|
|
13
|
+
//
|
|
14
|
+
// 1. Proprietary paths — an operator-listed glob (`proprietaryPaths`)
|
|
15
|
+
// whose CONTENT must never leave at all, matched via util/glob.js's
|
|
16
|
+
// matchesAnyGlob (reused, not reimplemented — the same matcher FR-602
|
|
17
|
+
// uses for egress-policy.yml's deniedPaths/allowedPaths). Whole-span
|
|
18
|
+
// replacement, not a substring redaction, and it short-circuits every
|
|
19
|
+
// other pass — there is nothing left to redact once the whole thing
|
|
20
|
+
// is gone.
|
|
21
|
+
// 2. Secrets — reuses llm-validator/redact.js's redactSecrets VERBATIM
|
|
22
|
+
// (the proven, independently-tested implementation described there;
|
|
23
|
+
// not reimplemented here). That module's own header already noted
|
|
24
|
+
// this was "already partially done ... for its own path only" before
|
|
25
|
+
// this cycle — this module is what makes it reachable beyond that one
|
|
26
|
+
// caller as a named, general capability.
|
|
27
|
+
// 3. PII/PHI/PCI/FIN — reuses dataflow/privacy-taxonomy.js's field-name
|
|
28
|
+
// vocabulary (FR-402) as the single source of truth for "what counts
|
|
29
|
+
// as PII" across the codebase, applied with the same
|
|
30
|
+
// `name(:|=)"value"` shape llm-validator/redact.js's KEY_VALUE_RE
|
|
31
|
+
// already proved works for secrets. The CREDENTIALS class is skipped
|
|
32
|
+
// here — pass 2 already owns that category via redactSecrets, and
|
|
33
|
+
// redacting it twice under two different placeholder names would be
|
|
34
|
+
// confusing without adding any actual protection.
|
|
35
|
+
// 4. Customer data — organization-defined only. egress-policy.yml's
|
|
36
|
+
// `customerDataPatterns` (raw regex strings, operator-authored). No
|
|
37
|
+
// built-in default: unlike PII, "customer data" has no generic shape
|
|
38
|
+
// the harness could safely guess (an internal account-number format,
|
|
39
|
+
// a tenant-naming scheme, ...) — this category is a no-op until an
|
|
40
|
+
// operator configures it, by design.
|
|
41
|
+
//
|
|
42
|
+
// Returns { text, redactions, categories: {...} } so a caller can report
|
|
43
|
+
// HOW MUCH was removed per category (FR-604's audit metadata wants exactly
|
|
44
|
+
// this: counts, not content) without retaining what was actually removed.
|
|
45
|
+
|
|
46
|
+
import { loadPolicyConfig } from './policy.js';
|
|
47
|
+
import { redactSecrets } from '../llm-validator/redact.js';
|
|
48
|
+
import { loadPrivacyTaxonomy } from '../dataflow/privacy-taxonomy.js';
|
|
49
|
+
import { matchesAnyGlob } from '../util/glob.js';
|
|
50
|
+
|
|
51
|
+
const PROPRIETARY_PLACEHOLDER = '[REDACTED-PROPRIETARY-CONTENT]';
|
|
52
|
+
const PII_PLACEHOLDER = '[REDACTED-PII]';
|
|
53
|
+
const CUSTOMER_DATA_PLACEHOLDER = '[REDACTED-CUSTOMER-DATA]';
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Build one `name(:|=)"value"` regex per taxonomy class (excluding
|
|
57
|
+
* CREDENTIALS — see module header). Mirrors llm-validator/redact.js's
|
|
58
|
+
* KEY_VALUE_RE shape exactly, generalized to an arbitrary field-name
|
|
59
|
+
* pattern list instead of a fixed word list.
|
|
60
|
+
*/
|
|
61
|
+
function _buildPiiKeyValueRegexes(taxonomy) {
|
|
62
|
+
const regexes = [];
|
|
63
|
+
for (const [cls, def] of Object.entries(taxonomy || {})) {
|
|
64
|
+
if (cls === 'CREDENTIALS') continue;
|
|
65
|
+
const patterns = Array.isArray(def?.patterns) ? def.patterns : [];
|
|
66
|
+
if (!patterns.length) continue;
|
|
67
|
+
let re;
|
|
68
|
+
try {
|
|
69
|
+
// Named groups, not positional (\1/\2/\3) — several taxonomy field
|
|
70
|
+
// patterns carry their OWN capturing group (e.g. PII's
|
|
71
|
+
// `email([_-]?address)?`), which would silently shift every
|
|
72
|
+
// positional backreference below it. \k<quote> sidesteps that
|
|
73
|
+
// entirely regardless of how many groups the alternation contains.
|
|
74
|
+
re = new RegExp(
|
|
75
|
+
'(?<keyName>' + patterns.join('|') + ')(?<opWs>\\s*[:=]\\s*)(?<quote>[\'"`])(?<value>[^\'"`]+)\\k<quote>',
|
|
76
|
+
'gi'
|
|
77
|
+
);
|
|
78
|
+
} catch {
|
|
79
|
+
continue; // an operator-supplied pattern (privacy-taxonomy.json) can be invalid regex
|
|
80
|
+
}
|
|
81
|
+
regexes.push(re);
|
|
82
|
+
}
|
|
83
|
+
return regexes;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Redact PII/PHI/PCI/FIN-shaped `key: "value"` / `key = "value"` spans.
|
|
88
|
+
* taxonomy defaults to the built-in DEFAULT_TAXONOMY when not supplied.
|
|
89
|
+
*/
|
|
90
|
+
export function redactPii(text, { taxonomy } = {}) {
|
|
91
|
+
if (typeof text !== 'string' || text.length === 0) {
|
|
92
|
+
return { text: typeof text === 'string' ? text : '', redactions: 0 };
|
|
93
|
+
}
|
|
94
|
+
const effectiveTaxonomy = taxonomy || loadPrivacyTaxonomy(null).taxonomy;
|
|
95
|
+
let out = text;
|
|
96
|
+
let redactions = 0;
|
|
97
|
+
for (const re of _buildPiiKeyValueRegexes(effectiveTaxonomy)) {
|
|
98
|
+
out = out.replace(re, (...args) => {
|
|
99
|
+
const groups = args[args.length - 1];
|
|
100
|
+
redactions++;
|
|
101
|
+
return `${groups.keyName}${groups.opWs}${groups.quote}${PII_PLACEHOLDER}${groups.quote}`;
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
return { text: out, redactions };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Redact organization-defined customer-data patterns. No-op with no patterns configured. */
|
|
108
|
+
export function redactCustomerData(text, { patterns = [] } = {}) {
|
|
109
|
+
if (typeof text !== 'string' || text.length === 0 || !patterns.length) {
|
|
110
|
+
return { text: typeof text === 'string' ? text : '', redactions: 0 };
|
|
111
|
+
}
|
|
112
|
+
let out = text;
|
|
113
|
+
let redactions = 0;
|
|
114
|
+
for (const p of patterns) {
|
|
115
|
+
let re;
|
|
116
|
+
try { re = new RegExp(p, 'gi'); } catch { continue; } // invalid operator-supplied regex — skip, don't throw
|
|
117
|
+
out = out.replace(re, () => { redactions++; return CUSTOMER_DATA_PLACEHOLDER; });
|
|
118
|
+
}
|
|
119
|
+
return { text: out, redactions };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** True when filePath matches an operator-configured proprietary-path glob. */
|
|
123
|
+
export function isProprietaryPath(filePath, { proprietaryPaths = [] } = {}) {
|
|
124
|
+
if (!filePath || !proprietaryPaths.length) return false;
|
|
125
|
+
return matchesAnyGlob(filePath, proprietaryPaths);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Apply the full redaction pipeline to one span of outbound prompt text.
|
|
130
|
+
*
|
|
131
|
+
* @param {object} opts
|
|
132
|
+
* @param {string} opts.text - the text about to be sent to a model endpoint.
|
|
133
|
+
* @param {string} [opts.filePath] - the source file this text was drawn
|
|
134
|
+
* from, if any — evaluated against `proprietaryPaths`.
|
|
135
|
+
* @param {string} [opts.scanRoot] - project root, for reading
|
|
136
|
+
* egress-policy.yml when `policy` is not already supplied.
|
|
137
|
+
* @param {object} [opts.taxonomy] - a pre-loaded privacy taxonomy (avoids
|
|
138
|
+
* re-reading privacy-taxonomy.json per call in a hot loop); defaults to
|
|
139
|
+
* loadPrivacyTaxonomy(scanRoot).taxonomy.
|
|
140
|
+
* @param {object} [opts.policy] - a pre-loaded egress-policy.yml document
|
|
141
|
+
* (avoids re-reading it per call); defaults to loadPolicyConfig(scanRoot).
|
|
142
|
+
* @returns {{text: string, redactions: number, categories: {proprietaryPath: number, secrets: number, pii: number, customerData: number}}}
|
|
143
|
+
*/
|
|
144
|
+
export function redactPayload({ text, filePath = null, scanRoot = null, taxonomy = null, policy = null } = {}) {
|
|
145
|
+
const categories = { proprietaryPath: 0, secrets: 0, pii: 0, customerData: 0 };
|
|
146
|
+
if (typeof text !== 'string' || text.length === 0) {
|
|
147
|
+
return { text: typeof text === 'string' ? text : '', redactions: 0, categories };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const cfg = policy || loadPolicyConfig(scanRoot) || {};
|
|
151
|
+
const proprietaryPaths = Array.isArray(cfg.proprietaryPaths) ? cfg.proprietaryPaths : [];
|
|
152
|
+
if (isProprietaryPath(filePath, { proprietaryPaths })) {
|
|
153
|
+
categories.proprietaryPath = 1;
|
|
154
|
+
return { text: PROPRIETARY_PLACEHOLDER, redactions: 1, categories };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
let out = text;
|
|
158
|
+
|
|
159
|
+
const secretResult = redactSecrets(out);
|
|
160
|
+
out = secretResult.text;
|
|
161
|
+
categories.secrets = secretResult.redactions;
|
|
162
|
+
|
|
163
|
+
const piiEnabled = cfg.redactPii !== false; // default ON — this category has a safe built-in default, unlike customerDataPatterns
|
|
164
|
+
if (piiEnabled) {
|
|
165
|
+
const effectiveTaxonomy = taxonomy || loadPrivacyTaxonomy(scanRoot).taxonomy;
|
|
166
|
+
const piiResult = redactPii(out, { taxonomy: effectiveTaxonomy });
|
|
167
|
+
out = piiResult.text;
|
|
168
|
+
categories.pii = piiResult.redactions;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const customerDataPatterns = Array.isArray(cfg.customerDataPatterns) ? cfg.customerDataPatterns : [];
|
|
172
|
+
const customerResult = redactCustomerData(out, { patterns: customerDataPatterns });
|
|
173
|
+
out = customerResult.text;
|
|
174
|
+
categories.customerData = customerResult.redactions;
|
|
175
|
+
|
|
176
|
+
const redactions = categories.proprietaryPath + categories.secrets + categories.pii + categories.customerData;
|
|
177
|
+
return { text: out, redactions, categories };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export const _internals = { _buildPiiKeyValueRegexes, PROPRIETARY_PLACEHOLDER, PII_PLACEHOLDER, CUSTOMER_DATA_PLACEHOLDER };
|