@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,515 @@
|
|
|
1
|
+
// FR-403 (assurance-hardening PRD) step 3: a genuinely SEPARATE, isolated
|
|
2
|
+
// CFG walker for privacy-classified (PII/PHI/PCI) taint, per D-0047.
|
|
3
|
+
//
|
|
4
|
+
// D-0047's decision, in full: the general taint engine's `step`/`exprTaint`
|
|
5
|
+
// (dataflow/engine.js) are the single hottest, most heavily-relied-upon
|
|
6
|
+
// functions in this codebase, already proven correct against the corpus and
|
|
7
|
+
// self-scan. Threading a second ("privacy") Set through their existing
|
|
8
|
+
// signature would touch every internal reference to the state parameter for
|
|
9
|
+
// an orthogonal, still-opt-in capability — an unacceptable risk to already-
|
|
10
|
+
// validated general-security taint accuracy. This file instead reuses ONLY
|
|
11
|
+
// the pure, generic Set utilities from access-paths.js (isCoveredBy/addPath/
|
|
12
|
+
// removePathAndDescendants/joinSets/setsEqual — none of which are specific
|
|
13
|
+
// to general-security taint semantics) and implements its OWN worklist and
|
|
14
|
+
// its OWN, much simpler expression-taint recursion, with ZERO shared
|
|
15
|
+
// mutable state and ZERO call-site overlap with `step`/`exprTaint`.
|
|
16
|
+
//
|
|
17
|
+
// DELIBERATE v1 SCOPE (intra-procedural only, per D-0047): no interprocedural
|
|
18
|
+
// SummaryCache, no higher-order taint flow, no k-CFA context sensitivity, no
|
|
19
|
+
// receiver-type (CHA) inference for matchPrivacySink's receiverType param
|
|
20
|
+
// (passed null — matchPrivacySink's "unknown != clean" contract means this
|
|
21
|
+
// never SUPPRESSES a real match, only widens which receiverTypeIn entries
|
|
22
|
+
// are permitted to fire, the safe recall-preserving direction). Each
|
|
23
|
+
// function is analyzed independently, in isolation from its callers/callees.
|
|
24
|
+
// This is a real, bounded capability — direct source-to-sink privacy taint
|
|
25
|
+
// within one function — not the full FR-403 acceptance criterion (which
|
|
26
|
+
// also needs interprocedural/cross-file tracking); it is the foundation
|
|
27
|
+
// those richer capabilities build on incrementally, mirroring how the
|
|
28
|
+
// general engine itself grew capability over many versions (see
|
|
29
|
+
// dataflow/CLAUDE.md's own "Scope — now modelled" history).
|
|
30
|
+
//
|
|
31
|
+
// STEP 3 ITEM (a), SIMPLE SAME-FUNCTION ALIASING (added after v1): a bare
|
|
32
|
+
// `let a = obj;` (identifier-to-identifier assignment) records `a`/`obj` as
|
|
33
|
+
// mutual aliases in a per-function `ctx.aliasesByVar` map. A later member
|
|
34
|
+
// WRITE through either name (`a.x = tainted`) applies to both; a later READ
|
|
35
|
+
// through either name (`sink(obj.x)`) resolves through the alias too. This
|
|
36
|
+
// is deliberately NOT a general points-to/alias analysis (no field-
|
|
37
|
+
// sensitive points-to graph, no branch-sensitivity, no alias chains through
|
|
38
|
+
// calls or containers) — just the single most common privacy-leak shape a
|
|
39
|
+
// declaration-based source needs: renaming a variable before it reaches a
|
|
40
|
+
// sink, which the general engine's own `points-to.js` (AGENTIC_SECURITY_
|
|
41
|
+
// POINTS_TO=1) solves far more generally for security taint but is
|
|
42
|
+
// deliberately NOT reused here, for the same D-0047 isolation reason
|
|
43
|
+
// `step`/`exprTaint` are not reused: consuming a shared, general-engine data
|
|
44
|
+
// structure would reintroduce a dependency this file's whole design avoids.
|
|
45
|
+
//
|
|
46
|
+
// OFF BY DEFAULT. Gated by AGENTIC_SECURITY_PRIVACY_DEEP=1 at the call site
|
|
47
|
+
// in dataflow/index.js's runDeepAnalysis (mirrors AGENTIC_SECURITY_IFDS=1's
|
|
48
|
+
// exact wiring shape: an additional, independently-failing pass whose
|
|
49
|
+
// findings are merged in, never replacing the primary engine's output).
|
|
50
|
+
// MUST be bench-compared against the existing shallow annotatePrivacyTaint
|
|
51
|
+
// (privacy-taint.js) before ever being considered for default-on — that
|
|
52
|
+
// comparison harness does not exist yet and is tracked as separate,
|
|
53
|
+
// necessary follow-up work, not done in this file.
|
|
54
|
+
|
|
55
|
+
import {
|
|
56
|
+
accessPathOf, isCoveredBy, addPath, removePathAndDescendants,
|
|
57
|
+
joinSets, setsEqual,
|
|
58
|
+
} from './access-paths.js';
|
|
59
|
+
import { matchPrivacyDeclSource, matchPrivacyDeclSources, matchPrivacySink, isPrivacyTransformCallee } from './privacy-catalog.js';
|
|
60
|
+
// FR-403 step 3, item (b): `functionRecord` is a pure, read-only lookup
|
|
61
|
+
// (callGraph.functions.get(qid), roughly) -- reused here for the same
|
|
62
|
+
// reason `_languageFamilyExtensions` (catalog.js) is reused: it is shared,
|
|
63
|
+
// stateless infrastructure, not the general engine's mutable taint state or
|
|
64
|
+
// its `step`/`exprTaint` hot path. `callGraph.resolveKnownCallee(...)` --
|
|
65
|
+
// the OTHER half of resolution -- is a method already attached to the
|
|
66
|
+
// callGraph object passed into runPrivacyTaintEngine, so it needs no import.
|
|
67
|
+
import { functionRecord } from '../ir/callgraph.js';
|
|
68
|
+
|
|
69
|
+
// FR-403 step 3, item (d) demotion factor. Mirrors stub-aware-filter.js's
|
|
70
|
+
// own demotion shape (dataflow/CLAUDE.md: "demotes confidence... by one
|
|
71
|
+
// step"), not a fixed magic number invented here.
|
|
72
|
+
const TRANSFORM_CONFIDENCE_FACTOR = 0.3;
|
|
73
|
+
|
|
74
|
+
// Human-readable callee name for recording on a finding -- handles both real
|
|
75
|
+
// IR callee shapes (a flat dotted string from the hand-rolled parsers, or a
|
|
76
|
+
// structured {kind:'member', object, prop} node from parser-js.js/Babel).
|
|
77
|
+
function _calleeDisplayName(calleeExpr) {
|
|
78
|
+
if (typeof calleeExpr === 'string') return calleeExpr;
|
|
79
|
+
if (calleeExpr && calleeExpr.kind === 'member' && calleeExpr.prop) {
|
|
80
|
+
const objName = calleeExpr.object && calleeExpr.object.kind === 'ident' ? calleeExpr.object.name : null;
|
|
81
|
+
return objName ? `${objName}.${calleeExpr.prop}` : calleeExpr.prop;
|
|
82
|
+
}
|
|
83
|
+
if (calleeExpr && calleeExpr.kind === 'ident') return calleeExpr.name || 'unknown';
|
|
84
|
+
return 'unknown';
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// FR-403 step 3, item (a): resolve a bare-identifier root through the
|
|
88
|
+
// per-function alias map before checking coverage. `ctx.aliasesByVar` is
|
|
89
|
+
// symmetric (a aliases b => b aliases a), so a single lookup on the
|
|
90
|
+
// expression's OWN root is sufficient — the caller does not need to also
|
|
91
|
+
// check from the "other side".
|
|
92
|
+
function _isCoveredByWithAliases(state, ap, ctx) {
|
|
93
|
+
if (isCoveredBy(state, ap)) return true;
|
|
94
|
+
if (!ctx || !ctx.aliasesByVar || !ap) return false;
|
|
95
|
+
const dot = ap.indexOf('.');
|
|
96
|
+
const root = dot === -1 ? ap : ap.slice(0, dot);
|
|
97
|
+
const rest = dot === -1 ? '' : ap.slice(dot);
|
|
98
|
+
const aliases = ctx.aliasesByVar.get(root);
|
|
99
|
+
if (!aliases || !aliases.size) return false;
|
|
100
|
+
for (const alias of aliases) {
|
|
101
|
+
if (isCoveredBy(state, alias + rest)) return true;
|
|
102
|
+
}
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Deliberately simpler than dataflow/engine.js's exprTaint: no receiver-
|
|
107
|
+
// taint propagation (_calleeReceiverTainted), no interprocedural callee-
|
|
108
|
+
// return-taint resolution (_nestedCallReturnTainted), no constant-
|
|
109
|
+
// propagation table. Those all exist to widen RECALL for general-security
|
|
110
|
+
// taint against an adversarial, heavily-obfuscated corpus; v1's job is a
|
|
111
|
+
// correct, bounded DIRECT-flow check, and adding them here would mean
|
|
112
|
+
// duplicating (and now independently maintaining) a meaningful slice of the
|
|
113
|
+
// general engine's own logic for a capability that does not need it yet.
|
|
114
|
+
//
|
|
115
|
+
// `ctx` is OPTIONAL (third param, backward-compatible with every existing
|
|
116
|
+
// caller/test) — when supplied, member/ident reads are also checked through
|
|
117
|
+
// the item (a) alias map, and 'call' expressions also check item (b)'s
|
|
118
|
+
// interprocedural return-taint resolution.
|
|
119
|
+
export function exprPrivacyTaint(expr, state, ctx) {
|
|
120
|
+
if (!expr) return false;
|
|
121
|
+
const ap = accessPathOf(expr);
|
|
122
|
+
if (ap !== null) return _isCoveredByWithAliases(state, ap, ctx);
|
|
123
|
+
switch (expr.kind) {
|
|
124
|
+
case 'literal': return false;
|
|
125
|
+
case 'binary':
|
|
126
|
+
case 'logical': return exprPrivacyTaint(expr.left, state, ctx) || exprPrivacyTaint(expr.right, state, ctx);
|
|
127
|
+
case 'tpl': return (expr.parts || []).some((p) => exprPrivacyTaint(p, state, ctx));
|
|
128
|
+
case 'union': return (expr.branches || []).some((b) => exprPrivacyTaint(b, state, ctx));
|
|
129
|
+
case 'object': return (expr.props || []).some((p) => exprPrivacyTaint(p.value, state, ctx));
|
|
130
|
+
case 'array': return (expr.elements || []).some((e) => exprPrivacyTaint(e, state, ctx));
|
|
131
|
+
case 'call': {
|
|
132
|
+
const argsTainted = (expr.args || []).some((a) => exprPrivacyTaint(a, state, ctx));
|
|
133
|
+
// FR-403 step 3, item (b): a call's own arguments being tainted is
|
|
134
|
+
// the common case; the callee's OWN return value can ALSO be tainted
|
|
135
|
+
// independent of these specific arguments' taint (e.g. the callee
|
|
136
|
+
// returns a closed-over module-level value) -- mirroring the general
|
|
137
|
+
// engine's exprTaint 'call' case, which evaluates BOTH sides rather
|
|
138
|
+
// than short-circuiting (Taint-recall PRD 80%'s own documented
|
|
139
|
+
// reasoning, in dataflow/engine.js, for why args-tainted alone is not
|
|
140
|
+
// sufficient). Both are cheap here since v1 has no summary-merge side
|
|
141
|
+
// effects to lose by short-circuiting; this just widens recall.
|
|
142
|
+
const nestedTainted = _nestedCallReturnPrivacyTainted(expr.callee, expr.args, state, ctx);
|
|
143
|
+
return argsTainted || nestedTainted;
|
|
144
|
+
}
|
|
145
|
+
default: return false;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// FR-403 step 3, item (b): resolve a SIMPLE (bare-identifier, no receiver)
|
|
150
|
+
// callee to a known function via the SAME callGraph the general engine
|
|
151
|
+
// builds -- callGraph.resolveKnownCallee()/functionRecord() are pure,
|
|
152
|
+
// read-only lookups, not the general engine's mutable taint state, so
|
|
153
|
+
// reusing them does not violate D-0047's isolation requirement (which is
|
|
154
|
+
// about `step`/`exprTaint`'s DATA SHAPE and the SummaryCache, not about
|
|
155
|
+
// call-graph name resolution). Deliberately as conservative as the general
|
|
156
|
+
// engine's own `_resolvableCalleeName`: refuses every member-expression/
|
|
157
|
+
// dotted-string callee (a receiver-qualified call needs CHA-style receiver
|
|
158
|
+
// resolution this file does not implement) -- a real, in-scope in-file
|
|
159
|
+
// helper call (`helper(x)`) is the only shape resolved.
|
|
160
|
+
function _resolvableCalleeNameSimple(calleeExpr) {
|
|
161
|
+
if (typeof calleeExpr === 'string') return calleeExpr.includes('.') ? null : calleeExpr;
|
|
162
|
+
if (calleeExpr && calleeExpr.kind === 'ident') return calleeExpr.name || null;
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Returns true iff calling `calleeExpr` with `argExprs` (evaluated against
|
|
167
|
+
// the CALLER's `state`) resolves to a known, analyzable function whose
|
|
168
|
+
// return value is tainted under the entry state those arguments imply.
|
|
169
|
+
// `ctx.interproc` (set by analyzePrivacyFunction/runPrivacyTaintEngine) is
|
|
170
|
+
// the shared-across-the-whole-scan {callGraph, summaryCache, stack}; absent
|
|
171
|
+
// (or missing a resolvable callGraph) degrades to false -- exactly the
|
|
172
|
+
// "not this capability, not a wrong answer" contract every optional ctx
|
|
173
|
+
// field in this file already follows.
|
|
174
|
+
function _nestedCallReturnPrivacyTainted(calleeExpr, argExprs, state, ctx) {
|
|
175
|
+
if (!ctx || !ctx.interproc || !ctx.interproc.callGraph) return false;
|
|
176
|
+
const name = _resolvableCalleeNameSimple(calleeExpr);
|
|
177
|
+
if (!name) return false;
|
|
178
|
+
const cg = ctx.interproc.callGraph;
|
|
179
|
+
if (typeof cg.resolveKnownCallee !== 'function') return false;
|
|
180
|
+
|
|
181
|
+
const callerFile = ctx.currentFn && ctx.currentFn.qid ? String(ctx.currentFn.qid).split('::')[0] : undefined;
|
|
182
|
+
let resolved;
|
|
183
|
+
try {
|
|
184
|
+
resolved = cg.resolveKnownCallee(name, callerFile);
|
|
185
|
+
} catch {
|
|
186
|
+
return false;
|
|
187
|
+
}
|
|
188
|
+
const fn = functionRecord(cg, resolved);
|
|
189
|
+
const qid = resolved && (resolved.qid || resolved);
|
|
190
|
+
if (!fn || typeof qid !== 'string' || !fn.cfg) return false;
|
|
191
|
+
|
|
192
|
+
// Recursion guard: a function already being analyzed higher up THIS same
|
|
193
|
+
// interprocedural chain is treated as returning untainted -- a sound,
|
|
194
|
+
// conservative "bottom" for a cycle, mirroring the general engine's own
|
|
195
|
+
// SummaryCache behavior for a function it finds already on its `_stack`.
|
|
196
|
+
if (ctx.interproc.stack.has(qid)) return false;
|
|
197
|
+
|
|
198
|
+
const paramNames = Array.isArray(fn.params) ? fn.params : [];
|
|
199
|
+
const declEntry = matchPrivacyDeclSources(paramNames, ctx.compiled);
|
|
200
|
+
const entrySet = new Set(declEntry.keys());
|
|
201
|
+
const taintedParamIdx = [];
|
|
202
|
+
(argExprs || []).forEach((a, i) => {
|
|
203
|
+
if (i < paramNames.length && exprPrivacyTaint(a, state, ctx)) {
|
|
204
|
+
entrySet.add(paramNames[i]);
|
|
205
|
+
taintedParamIdx.push(i);
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
// Context-sensitive on WHICH params are tainted (not the general engine's
|
|
210
|
+
// richer k-CFA context), keyed per callee -- a helper called once with
|
|
211
|
+
// clean args and once with a tainted arg gets two independent answers,
|
|
212
|
+
// exactly the shape FR-SEM-2 (the general engine's own value-context
|
|
213
|
+
// sensitivity) targets, just monovariant-per-call-site here rather than
|
|
214
|
+
// capped-and-cached the same elaborate way.
|
|
215
|
+
const cacheKey = `${qid}::${taintedParamIdx.join(',')}`;
|
|
216
|
+
if (ctx.interproc.summaryCache.has(cacheKey)) return ctx.interproc.summaryCache.get(cacheKey);
|
|
217
|
+
|
|
218
|
+
ctx.interproc.stack.add(qid);
|
|
219
|
+
let result = false;
|
|
220
|
+
try {
|
|
221
|
+
result = _computeReturnTaintUnderEntry(fn, ctx.compiled, entrySet, ctx.interproc);
|
|
222
|
+
} catch {
|
|
223
|
+
result = false;
|
|
224
|
+
} finally {
|
|
225
|
+
ctx.interproc.stack.delete(qid);
|
|
226
|
+
}
|
|
227
|
+
ctx.interproc.summaryCache.set(cacheKey, result);
|
|
228
|
+
return result;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// FR-403 step 3, item (d): which arg indices reached the sink through a
|
|
232
|
+
// value some named privacy-transform callee touched. `ctx.transformsByVar`
|
|
233
|
+
// is keyed by variable NAME (the root of an access path), mirroring
|
|
234
|
+
// engine.js's own `_sanitizersByVar` shape exactly.
|
|
235
|
+
function _transformsForArg(arg, ctx) {
|
|
236
|
+
if (!ctx || !ctx.transformsByVar || !ctx.transformsByVar.size) return null;
|
|
237
|
+
const ap = accessPathOf(arg);
|
|
238
|
+
if (!ap) return null;
|
|
239
|
+
const root = ap.includes('.') ? ap.slice(0, ap.indexOf('.')) : ap;
|
|
240
|
+
return ctx.transformsByVar.get(root) || null;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function _privacySinkFindingsForCall(callee, args, file, line, state, ctx) {
|
|
244
|
+
const hits = matchPrivacySink(callee, file, null);
|
|
245
|
+
if (!hits) return [];
|
|
246
|
+
const argTaints = (args || []).map((a) => exprPrivacyTaint(a, state, ctx));
|
|
247
|
+
const out = [];
|
|
248
|
+
for (const e of hits) {
|
|
249
|
+
const taintedArgIdx = e.argIndex === 'all'
|
|
250
|
+
? argTaints.findIndex(Boolean)
|
|
251
|
+
: (typeof e.argIndex === 'number' && argTaints[e.argIndex] ? e.argIndex : -1);
|
|
252
|
+
if (taintedArgIdx < 0) continue;
|
|
253
|
+
// Recall-preserving, exactly like the general engine's sanitizer
|
|
254
|
+
// handling (dataflow/CLAUDE.md): a transform callee DEMOTES confidence,
|
|
255
|
+
// it never suppresses the finding — a weak hash, a partial mask, or a
|
|
256
|
+
// transform applied on only one branch would otherwise become a real
|
|
257
|
+
// false negative, not a fixed false positive.
|
|
258
|
+
const transformNames = _transformsForArg((args || [])[taintedArgIdx], ctx);
|
|
259
|
+
const confidence = transformNames ? +(0.6 * TRANSFORM_CONFIDENCE_FACTOR).toFixed(3) : 0.6;
|
|
260
|
+
out.push({
|
|
261
|
+
id: `ir-privacy-taint:${file}:${line}:${e.id}`,
|
|
262
|
+
file,
|
|
263
|
+
line,
|
|
264
|
+
vuln: e.vuln.name,
|
|
265
|
+
cwe: e.vuln.cwe,
|
|
266
|
+
severity: e.vuln.severity,
|
|
267
|
+
remediation: e.vuln.remediation,
|
|
268
|
+
family: 'pii-exposure',
|
|
269
|
+
parser: 'IR-PRIVACY-TAINT',
|
|
270
|
+
confidence,
|
|
271
|
+
confidenceTier: transformNames ? 'low' : 'medium',
|
|
272
|
+
_sinkCategory: e.category,
|
|
273
|
+
...(transformNames ? { _privacyTransformsOnPath: [...transformNames] } : {}),
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
return out;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Mirrors dataflow/engine.js's step()'s node-kind switch shape, but only
|
|
280
|
+
// the subset of cases privacy taint needs (assign/call/return) — every
|
|
281
|
+
// other kind ('entry'/'exit'/'noop'/'loop-header'/'throw'/anything unknown)
|
|
282
|
+
// is a pure pass-through, exactly as in the general engine.
|
|
283
|
+
function stepPrivacy(node, stateIn, file, compiled, ctx) {
|
|
284
|
+
let state = new Set(stateIn);
|
|
285
|
+
const findings = [];
|
|
286
|
+
|
|
287
|
+
switch (node.kind) {
|
|
288
|
+
case 'assign': {
|
|
289
|
+
const target = typeof node.target === 'string' ? node.target : null;
|
|
290
|
+
if (node.source && node.source.kind === 'call') {
|
|
291
|
+
findings.push(..._privacySinkFindingsForCall(node.source.callee, node.source.args, file, node.line, state, ctx));
|
|
292
|
+
}
|
|
293
|
+
if (target) {
|
|
294
|
+
const rhsTainted = exprPrivacyTaint(node.source, state, ctx);
|
|
295
|
+
// D-0041 step 2's whole reason for existing: the DECLARATION NAME
|
|
296
|
+
// itself, independent of what value it currently holds, can be a
|
|
297
|
+
// privacy source (e.g. `let medicalRecordNumber;` later assigned a
|
|
298
|
+
// plain local computation still deserves scrutiny at its sink).
|
|
299
|
+
const isMemberTarget = target.includes('.');
|
|
300
|
+
const baseName = isMemberTarget ? target.slice(0, target.indexOf('.')) : target;
|
|
301
|
+
const declHit = matchPrivacyDeclSource(baseName, compiled);
|
|
302
|
+
if (rhsTainted || declHit) {
|
|
303
|
+
state = addPath(state, target);
|
|
304
|
+
} else {
|
|
305
|
+
state = removePathAndDescendants(state, target);
|
|
306
|
+
}
|
|
307
|
+
// FR-403 step 3, item (a): a MEMBER write's effect must also reach
|
|
308
|
+
// any known alias of its root — `let a = obj; a.x = tainted;` must
|
|
309
|
+
// taint `obj.x` too, or a later `sink(obj.x)` would (wrongly) read
|
|
310
|
+
// as clean. Bare-identifier targets don't need this: a plain
|
|
311
|
+
// `a = tainted` already taints `a` itself above, and `a`'s own
|
|
312
|
+
// aliases are resolved at READ time by exprPrivacyTaint instead
|
|
313
|
+
// (an alias relationship is symmetric, not a live pointer -- see
|
|
314
|
+
// _isCoveredByWithAliases).
|
|
315
|
+
if (ctx && ctx.aliasesByVar && isMemberTarget) {
|
|
316
|
+
const rest = target.slice(target.indexOf('.'));
|
|
317
|
+
const aliases = ctx.aliasesByVar.get(baseName);
|
|
318
|
+
if (aliases && aliases.size) {
|
|
319
|
+
for (const alias of aliases) {
|
|
320
|
+
const aliasPath = alias + rest;
|
|
321
|
+
state = (rhsTainted || declHit) ? addPath(state, aliasPath) : removePathAndDescendants(state, aliasPath);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
// FR-403 step 3, item (a): record/clear the alias relationship
|
|
326
|
+
// itself. A plain `a = obj` (bare ident RHS, bare ident target) is
|
|
327
|
+
// an alias; anything else assigned to a bare identifier invalidates
|
|
328
|
+
// whatever it used to alias (mirrors the taint-clearing branch
|
|
329
|
+
// above: a clean or unrelated reassignment ends the old relationship).
|
|
330
|
+
if (ctx && ctx.aliasesByVar && !isMemberTarget) {
|
|
331
|
+
// Sever every EXISTING alias relationship `target` had before
|
|
332
|
+
// this assignment, in both directions, first. Without this, a
|
|
333
|
+
// reassignment (`a = other` after `a = obj`) would ADD `other`
|
|
334
|
+
// to `a`'s alias set while leaving the stale `obj` entry behind,
|
|
335
|
+
// so a later `a.x = ...` would keep (wrongly) tainting `obj.x`
|
|
336
|
+
// forever after `a` had already moved on.
|
|
337
|
+
const staleAliases = ctx.aliasesByVar.get(target);
|
|
338
|
+
if (staleAliases) {
|
|
339
|
+
for (const stale of staleAliases) {
|
|
340
|
+
const staleSet = ctx.aliasesByVar.get(stale);
|
|
341
|
+
if (staleSet) staleSet.delete(target);
|
|
342
|
+
}
|
|
343
|
+
ctx.aliasesByVar.delete(target);
|
|
344
|
+
}
|
|
345
|
+
if (node.source && node.source.kind === 'ident' && node.source.name) {
|
|
346
|
+
const srcName = node.source.name;
|
|
347
|
+
if (srcName !== target) {
|
|
348
|
+
if (!ctx.aliasesByVar.has(target)) ctx.aliasesByVar.set(target, new Set());
|
|
349
|
+
if (!ctx.aliasesByVar.has(srcName)) ctx.aliasesByVar.set(srcName, new Set());
|
|
350
|
+
ctx.aliasesByVar.get(target).add(srcName);
|
|
351
|
+
ctx.aliasesByVar.get(srcName).add(target);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
// FR-403 step 3, item (d): record which named transform (if any)
|
|
356
|
+
// produced this value, mirroring engine.js's own _sanitizersByVar
|
|
357
|
+
// (updated on every assignment, cleared when the RHS doesn't apply
|
|
358
|
+
// one) — a re-assignment from a NON-transform source must not keep
|
|
359
|
+
// stealing an earlier transform's credit for the same variable name.
|
|
360
|
+
if (ctx && ctx.transformsByVar) {
|
|
361
|
+
if (node.source && node.source.kind === 'call' && isPrivacyTransformCallee(node.source.callee)) {
|
|
362
|
+
ctx.transformsByVar.set(target, new Set([_calleeDisplayName(node.source.callee)]));
|
|
363
|
+
} else {
|
|
364
|
+
ctx.transformsByVar.delete(target);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
return { state, findings };
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
case 'call': {
|
|
372
|
+
findings.push(..._privacySinkFindingsForCall(node.callee, node.args, file, node.line, state, ctx));
|
|
373
|
+
return { state, findings };
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
case 'return': {
|
|
377
|
+
if (node.value && node.value.kind === 'call') {
|
|
378
|
+
findings.push(..._privacySinkFindingsForCall(node.value.callee, node.value.args, file, node.line, state, ctx));
|
|
379
|
+
}
|
|
380
|
+
// FR-403 step 3, item (b): record whether THIS function's return
|
|
381
|
+
// value is tainted under the entry state it was analyzed with --
|
|
382
|
+
// mirrors dataflow/engine.js's own `case 'return'` exactly
|
|
383
|
+
// (`callContext._returnTainted = true`). Multiple return statements
|
|
384
|
+
// OR together (any tainted return makes the function's result
|
|
385
|
+
// tainted under this entry state); `ctx.returnTainted` is undefined
|
|
386
|
+
// when no interprocedural caller cares, so this is a no-op cost for
|
|
387
|
+
// every ordinary per-file analysis.
|
|
388
|
+
if (ctx && exprPrivacyTaint(node.value, state, ctx)) {
|
|
389
|
+
ctx.returnTainted = true;
|
|
390
|
+
}
|
|
391
|
+
return { state, findings };
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
default:
|
|
395
|
+
return { state, findings };
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// Shared worklist core, extracted so both the real per-function analysis
|
|
400
|
+
// (analyzePrivacyFunction) and the interprocedural return-taint probe
|
|
401
|
+
// (_computeReturnTaintUnderEntry) run the IDENTICAL walk -- a probe that
|
|
402
|
+
// diverged from the real analysis logic would be its own source of bugs.
|
|
403
|
+
// Returns findings; mutates `ctx.returnTainted` as a side effect (read by
|
|
404
|
+
// the caller when it cares, ignored otherwise).
|
|
405
|
+
function _runWorklist(fn, entrySet, compiled, ctx) {
|
|
406
|
+
const nodes = fn.cfg.nodes;
|
|
407
|
+
const file = fn.file || null;
|
|
408
|
+
const inStates = new Map();
|
|
409
|
+
const outStates = new Map();
|
|
410
|
+
inStates.set(fn.cfg.entry, entrySet);
|
|
411
|
+
const work = [fn.cfg.entry];
|
|
412
|
+
const findings = [];
|
|
413
|
+
|
|
414
|
+
let iterations = 0;
|
|
415
|
+
const ITER_BUDGET = 5000;
|
|
416
|
+
while (work.length) {
|
|
417
|
+
if (++iterations > ITER_BUDGET) break;
|
|
418
|
+
const nid = work.shift();
|
|
419
|
+
const node = nodes[nid];
|
|
420
|
+
if (!node) continue;
|
|
421
|
+
const incoming = inStates.get(nid) || new Set();
|
|
422
|
+
const { state: out, findings: nodeFindings } = stepPrivacy(node, incoming, file, compiled, ctx);
|
|
423
|
+
findings.push(...nodeFindings);
|
|
424
|
+
const prevOut = outStates.get(nid);
|
|
425
|
+
const merged = joinSets(prevOut, out);
|
|
426
|
+
if (!prevOut || !setsEqual(prevOut, merged)) {
|
|
427
|
+
outStates.set(nid, merged);
|
|
428
|
+
for (const s of (node.succ || [])) {
|
|
429
|
+
const succIn = inStates.get(s);
|
|
430
|
+
const newIn = joinSets(succIn, merged);
|
|
431
|
+
if (!succIn || !setsEqual(succIn, newIn)) {
|
|
432
|
+
inStates.set(s, newIn);
|
|
433
|
+
work.push(s);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
return findings;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function _freshCtx(compiled, interprocCtx, fn) {
|
|
442
|
+
return {
|
|
443
|
+
transformsByVar: new Map(),
|
|
444
|
+
aliasesByVar: new Map(),
|
|
445
|
+
returnTainted: false,
|
|
446
|
+
compiled,
|
|
447
|
+
interproc: interprocCtx || null,
|
|
448
|
+
currentFn: fn,
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// FR-403 step 3, item (b): probe whether `fn`'s return value is tainted
|
|
453
|
+
// under a SPECIFIC caller-supplied `entrySet` -- called only from
|
|
454
|
+
// _nestedCallReturnPrivacyTainted, never from the top-level per-file loop.
|
|
455
|
+
// Findings produced by this probe are DISCARDED: they would either
|
|
456
|
+
// duplicate what the function's own real per-file analysis already found
|
|
457
|
+
// (under its OWN, real entry state), or belong to a hypothetical entry
|
|
458
|
+
// state that does not correspond to any actual call in the file being
|
|
459
|
+
// reported on -- reporting them would be confusing, not more complete.
|
|
460
|
+
function _computeReturnTaintUnderEntry(fn, compiled, entrySet, interprocCtx) {
|
|
461
|
+
const ctx = _freshCtx(compiled, interprocCtx, fn);
|
|
462
|
+
_runWorklist(fn, entrySet, compiled, ctx);
|
|
463
|
+
return ctx.returnTainted;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// Intra-procedural entry state is seeded from this function's OWN declared
|
|
467
|
+
// parameter names (declaration-based sources); `interprocCtx`, when
|
|
468
|
+
// supplied by runPrivacyTaintEngine, additionally lets item (b)'s
|
|
469
|
+
// interprocedural resolution look up and probe OTHER functions this one
|
|
470
|
+
// calls. Omitting it (or calling with the old 2-arg form) degrades cleanly
|
|
471
|
+
// to the original intra-procedural-only v1 behavior -- backward-compatible
|
|
472
|
+
// with every existing direct caller/test.
|
|
473
|
+
export function analyzePrivacyFunction(fn, compiled, interprocCtx) {
|
|
474
|
+
if (!fn || !fn.cfg || !fn.cfg.nodes || fn.cfg.entry == null) return [];
|
|
475
|
+
const declHits = matchPrivacyDeclSources(fn.params || [], compiled);
|
|
476
|
+
const entrySet = new Set(declHits.keys());
|
|
477
|
+
const ctx = _freshCtx(compiled, interprocCtx, fn);
|
|
478
|
+
return _runWorklist(fn, entrySet, compiled, ctx);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* Public entry point, mirroring runTaintEngine(perFileIR, callGraph, opts)'s
|
|
483
|
+
* shape closely enough that dataflow/index.js's runDeepAnalysis can call it
|
|
484
|
+
* the same way it already calls the opt-in IFDS engine — an additional
|
|
485
|
+
* pass whose findings are merged in, never replacing the primary engine's
|
|
486
|
+
* output, and whose own failure (per function OR as a whole) must never
|
|
487
|
+
* fail the containing scan.
|
|
488
|
+
*
|
|
489
|
+
* @param {object} callGraph - the same callGraph object runTaintEngine consumes (callGraph.functions: Map<qid, fn>).
|
|
490
|
+
* @param {object} [opts]
|
|
491
|
+
* @param {object} [opts.compiledTaxonomy] - passed through to matchPrivacyDeclSource(s); undefined uses the built-in default taxonomy.
|
|
492
|
+
* @returns {Array} findings, family:'pii-exposure', cwe:'CWE-359', parser:'IR-PRIVACY-TAINT'.
|
|
493
|
+
*/
|
|
494
|
+
export function runPrivacyTaintEngine(callGraph, opts = {}) {
|
|
495
|
+
if (!callGraph || !callGraph.functions) return [];
|
|
496
|
+
const compiled = opts.compiledTaxonomy;
|
|
497
|
+
const fnList = [...callGraph.functions.values()].sort((a, b) =>
|
|
498
|
+
a.qid < b.qid ? -1 : a.qid > b.qid ? 1 : 0);
|
|
499
|
+
// FR-403 step 3, item (b): shared across the WHOLE scan (not per-function),
|
|
500
|
+
// mirroring the general engine's own SummaryCache/`_stack` scope exactly --
|
|
501
|
+
// a summary computed while analyzing one function is reusable by every
|
|
502
|
+
// other caller of the same callee, and the recursion guard must see the
|
|
503
|
+
// full chain, not just one function's own local view of it.
|
|
504
|
+
const interprocCtx = { callGraph, summaryCache: new Map(), stack: new Set() };
|
|
505
|
+
const all = [];
|
|
506
|
+
for (const fn of fnList) {
|
|
507
|
+
try {
|
|
508
|
+
all.push(...analyzePrivacyFunction(fn, compiled, interprocCtx));
|
|
509
|
+
} catch {
|
|
510
|
+
// One function's failure must not fail the whole privacy-deep pass,
|
|
511
|
+
// exactly mirroring runTaintEngine's own per-function try/catch.
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
return all;
|
|
515
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// Privacy governance fields for DPIA / RoPA scaffolds (assurance-hardening
|
|
2
|
+
// PRD FR-407).
|
|
3
|
+
//
|
|
4
|
+
// The DPIA artifact (privacy-taint.js#emitDpiaArtifact) already lists WHAT
|
|
5
|
+
// regulated data exists and WHERE it flows — code-derivable facts. GDPR
|
|
6
|
+
// Art. 30's Record of Processing Activities (RoPA) and a real DPIA both
|
|
7
|
+
// also need a second, DIFFERENT kind of fact this engine cannot see at all:
|
|
8
|
+
// purpose, lawful basis, data subject category, retention period, data
|
|
9
|
+
// residency, recipient, international transfer mechanism, minimization
|
|
10
|
+
// rationale, consent mechanism, access rights process, deletion process.
|
|
11
|
+
// None of these are inferable from source code — they are organizational
|
|
12
|
+
// and legal decisions.
|
|
13
|
+
//
|
|
14
|
+
// The failure mode this module exists to prevent is the same one
|
|
15
|
+
// privacy-framework.js's four-bucket model and FR-405's IR-backed gate both
|
|
16
|
+
// guard against: presenting an UNKNOWN governance fact as though it were
|
|
17
|
+
// verified. Every field defaults to the literal sentinel
|
|
18
|
+
// MANUAL_REQUIRED — never blank, never inferred, never silently omitted —
|
|
19
|
+
// so a scaffold reader sees exactly which facts are missing rather than
|
|
20
|
+
// reading absence as "not applicable." An operator MAY supply values via
|
|
21
|
+
// .agentic-security/privacy-governance.json (per data class, or a
|
|
22
|
+
// scan-wide default); a supplied value is always distinguishable from a
|
|
23
|
+
// manual_required one downstream (governanceRecordFor tags each field's
|
|
24
|
+
// source), the same "state the provenance, never launder it" discipline
|
|
25
|
+
// evidence-provenance.js (FR-107) established for finding fields.
|
|
26
|
+
|
|
27
|
+
import * as fs from 'node:fs';
|
|
28
|
+
import { statePath } from '../posture/state-dir.js';
|
|
29
|
+
|
|
30
|
+
export const MANUAL_REQUIRED = 'manual_required';
|
|
31
|
+
|
|
32
|
+
// The 11 fields FR-407 names by name, in the PRD's own order.
|
|
33
|
+
export const GOVERNANCE_FIELDS = [
|
|
34
|
+
'purpose', 'lawfulBasis', 'subject', 'retention', 'residency',
|
|
35
|
+
'recipient', 'transfer', 'minimization', 'consent', 'access', 'deletion',
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
function _governanceStatePath(scanRoot) {
|
|
39
|
+
return statePath(scanRoot, 'privacy-governance.json');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Load the operator's privacy governance config. Never throws — a missing
|
|
44
|
+
* file (ENOENT) is the common case (nothing configured, every field stays
|
|
45
|
+
* manual_required); a malformed one logs a warning and degrades the same
|
|
46
|
+
* way. Returns { byClass: {}, default: {} }.
|
|
47
|
+
*/
|
|
48
|
+
export function loadPrivacyGovernanceConfig(scanRoot) {
|
|
49
|
+
const EMPTY = { byClass: {}, default: {} };
|
|
50
|
+
if (!scanRoot) return EMPTY;
|
|
51
|
+
const fp = _governanceStatePath(scanRoot);
|
|
52
|
+
let raw = null;
|
|
53
|
+
try {
|
|
54
|
+
raw = JSON.parse(fs.readFileSync(fp, 'utf8'));
|
|
55
|
+
} catch (e) {
|
|
56
|
+
if (e.code !== 'ENOENT') {
|
|
57
|
+
console.error(`agentic-security: bad JSON in privacy-governance.json — falling back to no configured governance fields (${e.message})`);
|
|
58
|
+
}
|
|
59
|
+
return EMPTY;
|
|
60
|
+
}
|
|
61
|
+
const byClass = (raw && typeof raw.byClass === 'object' && raw.byClass) ? raw.byClass : {};
|
|
62
|
+
const def = (raw && typeof raw.default === 'object' && raw.default) ? raw.default : {};
|
|
63
|
+
return { byClass, default: def };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The governance record for one data class: every GOVERNANCE_FIELDS entry,
|
|
68
|
+
* each either an operator-supplied string (source: 'operator_provided') or
|
|
69
|
+
* the MANUAL_REQUIRED sentinel (source: 'manual_required') — precedence is
|
|
70
|
+
* per-class config, then scan-wide default, then manual_required. Never
|
|
71
|
+
* infers a value from anything code-derived; the caller (dataClass) exists
|
|
72
|
+
* only to select which config entry applies, not to compute an answer.
|
|
73
|
+
*/
|
|
74
|
+
export function governanceRecordFor(dataClass, config) {
|
|
75
|
+
const byClass = (config?.byClass && config.byClass[dataClass]) || {};
|
|
76
|
+
const def = config?.default || {};
|
|
77
|
+
const record = {};
|
|
78
|
+
for (const field of GOVERNANCE_FIELDS) {
|
|
79
|
+
const value = typeof byClass[field] === 'string' && byClass[field]
|
|
80
|
+
? byClass[field]
|
|
81
|
+
: (typeof def[field] === 'string' && def[field] ? def[field] : null);
|
|
82
|
+
record[field] = value
|
|
83
|
+
? { value, source: 'operator_provided' }
|
|
84
|
+
: { value: MANUAL_REQUIRED, source: 'manual_required' };
|
|
85
|
+
}
|
|
86
|
+
return record;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Emit a RoPA (Record of Processing Activities, GDPR Art. 30) scaffold —
|
|
91
|
+
* one row per data class, all 11 governance fields as columns. This is
|
|
92
|
+
* deliberately a register, not narrative (unlike the DPIA), matching how a
|
|
93
|
+
* real RoPA is used: an at-a-glance table a privacy officer or auditor
|
|
94
|
+
* scans for gaps.
|
|
95
|
+
*/
|
|
96
|
+
export function emitRopaArtifact(piiFields, config) {
|
|
97
|
+
const classes = [...new Set((piiFields || []).flatMap(f => f.classes || []))].sort();
|
|
98
|
+
const lines = [];
|
|
99
|
+
lines.push('# Record of Processing Activities (RoPA) — GDPR Art. 30 scaffold');
|
|
100
|
+
lines.push('');
|
|
101
|
+
lines.push('Generated by agentic-security scanner. Every field below is either');
|
|
102
|
+
lines.push(`supplied by an operator (.agentic-security/privacy-governance.json) or`);
|
|
103
|
+
lines.push(`marked \`${MANUAL_REQUIRED}\` — none of these fields are inferable from`);
|
|
104
|
+
lines.push('source code, and none are guessed. A privacy officer must fill in every');
|
|
105
|
+
lines.push(`\`${MANUAL_REQUIRED}\` cell before this document is usable as a real RoPA.`);
|
|
106
|
+
lines.push('');
|
|
107
|
+
if (!classes.length) {
|
|
108
|
+
lines.push('No regulated data classes were identified in this scan.');
|
|
109
|
+
return lines.join('\n');
|
|
110
|
+
}
|
|
111
|
+
const header = ['Data class', ...GOVERNANCE_FIELDS];
|
|
112
|
+
lines.push(`| ${header.join(' | ')} |`);
|
|
113
|
+
lines.push(`|${header.map(() => '---').join('|')}|`);
|
|
114
|
+
for (const cls of classes) {
|
|
115
|
+
const record = governanceRecordFor(cls, config);
|
|
116
|
+
const row = [cls, ...GOVERNANCE_FIELDS.map(f => record[f].value)];
|
|
117
|
+
lines.push(`| ${row.join(' | ')} |`);
|
|
118
|
+
}
|
|
119
|
+
lines.push('');
|
|
120
|
+
const gapCount = classes.reduce((n, cls) => {
|
|
121
|
+
const record = governanceRecordFor(cls, config);
|
|
122
|
+
return n + GOVERNANCE_FIELDS.filter(f => record[f].source === 'manual_required').length;
|
|
123
|
+
}, 0);
|
|
124
|
+
lines.push(`${gapCount} field(s) across ${classes.length} data class(es) require manual input.`);
|
|
125
|
+
return lines.join('\n');
|
|
126
|
+
}
|