@clear-capabilities/agentic-security-scanner 0.142.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 +399 -0
- package/bin/agentic-security.js +530 -54
- 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 +83 -18
- package/src/report/oscal.js +635 -0
- package/src/sast/cpp.js +3 -14
- package/src/sca/llm-function-extract.js +6 -0
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// Code-derived privacy data inventory + flow graph (assurance-hardening
|
|
2
|
+
// PRD FR-406).
|
|
3
|
+
//
|
|
4
|
+
// Turns the raw signals privacy-taint.js already computes (piiFields,
|
|
5
|
+
// findings, policyExemptions — the latter two now carrying sourceName/
|
|
6
|
+
// sourceLine, added for this requirement) into per-source-flow inventory
|
|
7
|
+
// records, each carrying the PRD's named fields: data class, source,
|
|
8
|
+
// transformations, storage, sink/recipient, evidence locations.
|
|
9
|
+
//
|
|
10
|
+
// "transformations" is honestly disclosed as the literal sentinel
|
|
11
|
+
// NOT_MODELED for every record — this engine has no sanitizer/
|
|
12
|
+
// transformation tracking for privacy flows specifically; that is FR-403's
|
|
13
|
+
// deferred scope (confirmed large this session, needing "an actual
|
|
14
|
+
// dataflow-aware rewrite"). Fabricating a transformation chain this engine
|
|
15
|
+
// cannot see would be exactly the vacuous-satisfaction failure mode
|
|
16
|
+
// FR-405/FR-407 both guard against for their own fields — this module
|
|
17
|
+
// applies the identical discipline here rather than inventing partial
|
|
18
|
+
// credit.
|
|
19
|
+
//
|
|
20
|
+
// STORAGE_SINK_KINDS names the subset of privacy-taint.js's sink
|
|
21
|
+
// categories that represent data AT REST (file writes, object storage) as
|
|
22
|
+
// opposed to data IN TRANSIT (log, response, outbound HTTP, third-party
|
|
23
|
+
// SDK, email) — this is what separates the PRD's "storage" field from its
|
|
24
|
+
// "sink/recipient" field. It is necessarily incomplete: this engine has no
|
|
25
|
+
// sink entries for a database write, a message queue, or most managed
|
|
26
|
+
// storage services, so an empty storage array does NOT mean "no storage
|
|
27
|
+
// exists" — the artifact says so explicitly rather than implying a
|
|
28
|
+
// negative that was never checked.
|
|
29
|
+
|
|
30
|
+
export const NOT_MODELED = 'not_modeled';
|
|
31
|
+
export const STORAGE_SINK_KINDS = new Set(['fileWrite', 's3Upload']);
|
|
32
|
+
|
|
33
|
+
function _evidenceLocations(record) {
|
|
34
|
+
const locs = [];
|
|
35
|
+
if (record.source) locs.push({ file: record.source.file, line: record.source.line, role: 'source' });
|
|
36
|
+
for (const s of record.storage) locs.push({ file: s.file, line: s.line, role: 'storage' });
|
|
37
|
+
for (const r of record.sinkRecipient) locs.push({ file: r.file, line: r.line, role: 'recipient' });
|
|
38
|
+
return locs;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Build the data inventory: one record per (data class, source variable)
|
|
43
|
+
* pair actually observed reaching at least one sink, prohibited or
|
|
44
|
+
* policy-permitted. A source with no observed sink at all (declared but
|
|
45
|
+
* never flowed anywhere the annotator's sink catalog recognizes) is NOT
|
|
46
|
+
* included — this is an inventory of REAL, observed flows, not a listing
|
|
47
|
+
* of every regulated-looking field name.
|
|
48
|
+
*/
|
|
49
|
+
export function buildDataInventory(piiFields, findings, policyExemptions) {
|
|
50
|
+
// Key: `${file}::${sourceName}::${classes.join(',')}` — the same source
|
|
51
|
+
// variable can carry more than one class (e.g. name+PII, ssn+PII), and a
|
|
52
|
+
// record must not merge two variables that happen to share a name across
|
|
53
|
+
// different files.
|
|
54
|
+
const byKey = new Map();
|
|
55
|
+
|
|
56
|
+
const declByFileName = new Map(); // `${file}::${name}` -> piiField (for declaredType / line fallback)
|
|
57
|
+
for (const f of piiFields || []) {
|
|
58
|
+
declByFileName.set(`${f.file}::${f.name}`, f);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function recordFor(file, name, classes, sourceLine) {
|
|
62
|
+
const key = `${file}::${name}::${classes.join(',')}`;
|
|
63
|
+
let rec = byKey.get(key);
|
|
64
|
+
if (!rec) {
|
|
65
|
+
const decl = declByFileName.get(`${file}::${name}`);
|
|
66
|
+
rec = {
|
|
67
|
+
dataClass: classes,
|
|
68
|
+
source: { file, name, line: sourceLine ?? decl?.line ?? null, declaredType: decl?.declaredType || null },
|
|
69
|
+
transformations: NOT_MODELED,
|
|
70
|
+
storage: [],
|
|
71
|
+
sinkRecipient: [],
|
|
72
|
+
};
|
|
73
|
+
byKey.set(key, rec);
|
|
74
|
+
}
|
|
75
|
+
return rec;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
for (const f of findings || []) {
|
|
79
|
+
if (f.family !== 'pii-exposure' || !f.sourceName) continue;
|
|
80
|
+
const rec = recordFor(f.file, f.sourceName, f.piiClass || [], f.sourceLine);
|
|
81
|
+
const entry = { sinkKind: f.sinkKind, file: f.file, line: f.line, status: 'prohibited', severity: f.severity };
|
|
82
|
+
if (STORAGE_SINK_KINDS.has(f.sinkKind)) rec.storage.push(entry);
|
|
83
|
+
else rec.sinkRecipient.push(entry);
|
|
84
|
+
}
|
|
85
|
+
for (const e of policyExemptions || []) {
|
|
86
|
+
if (!e.name) continue;
|
|
87
|
+
const rec = recordFor(e.file, e.name, e.classes || [], e.sourceLine);
|
|
88
|
+
const reasons = (e.rules || []).map(r => r.reason).filter(Boolean);
|
|
89
|
+
const entry = { sinkKind: e.sinkKind, file: e.file, line: e.line, status: 'policy_permitted', reason: reasons[0] || null };
|
|
90
|
+
if (STORAGE_SINK_KINDS.has(e.sinkKind)) rec.storage.push(entry);
|
|
91
|
+
else rec.sinkRecipient.push(entry);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const records = [...byKey.values()];
|
|
95
|
+
for (const rec of records) rec.evidenceLocations = _evidenceLocations(rec);
|
|
96
|
+
records.sort((a, b) => (a.source.file + a.source.name).localeCompare(b.source.file + b.source.name));
|
|
97
|
+
return records;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Emit the machine-readable inventory artifact.
|
|
102
|
+
*/
|
|
103
|
+
export function emitDataInventoryArtifact(records) {
|
|
104
|
+
return JSON.stringify({
|
|
105
|
+
schemaNote: 'Code-derived privacy data inventory (assurance-hardening PRD FR-406). "transformations" is always "not_modeled" — this engine does not track sanitization/masking/encoding for privacy flows (see FR-403). "storage" is best-effort — only file-write and object-storage sinks are recognized; a database, queue, or managed storage service reached through an unrecognized API will not appear here.',
|
|
106
|
+
generatedAt: new Date().toISOString(),
|
|
107
|
+
records,
|
|
108
|
+
}, null, 2);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Emit a Mermaid flow-graph: one edge per source -> sink/storage
|
|
113
|
+
* relationship, decorated with the data class and whether the flow is
|
|
114
|
+
* prohibited (still an open finding) or policy-permitted.
|
|
115
|
+
*/
|
|
116
|
+
export function emitDataFlowGraph(records) {
|
|
117
|
+
const lines = [];
|
|
118
|
+
lines.push('# Privacy data flow graph (assurance-hardening PRD FR-406)');
|
|
119
|
+
lines.push('');
|
|
120
|
+
lines.push('Code-derived — one edge per OBSERVED source -> sink/storage flow.');
|
|
121
|
+
lines.push('A red edge is an open (prohibited) finding; a green edge is policy-permitted.');
|
|
122
|
+
lines.push('');
|
|
123
|
+
lines.push('```mermaid');
|
|
124
|
+
lines.push('graph LR');
|
|
125
|
+
const nodeIds = new Map();
|
|
126
|
+
let n = 0;
|
|
127
|
+
function idFor(label) {
|
|
128
|
+
if (!nodeIds.has(label)) nodeIds.set(label, `n${n++}`);
|
|
129
|
+
return nodeIds.get(label);
|
|
130
|
+
}
|
|
131
|
+
if (!records.length) {
|
|
132
|
+
lines.push(' EMPTY["No regulated-data flows observed"]');
|
|
133
|
+
}
|
|
134
|
+
const edgeLines = [];
|
|
135
|
+
const styleLines = [];
|
|
136
|
+
let edgeIndex = 0;
|
|
137
|
+
for (const rec of records) {
|
|
138
|
+
const srcLabel = `${rec.source.name}\\n(${rec.dataClass.join('+')})\\n${rec.source.file}:${rec.source.line ?? '?'}`;
|
|
139
|
+
const srcId = idFor(srcLabel);
|
|
140
|
+
lines.push(` ${srcId}["${srcLabel}"]`);
|
|
141
|
+
for (const entry of [...rec.storage, ...rec.sinkRecipient]) {
|
|
142
|
+
const sinkLabel = `${entry.sinkKind}\\n${entry.file}:${entry.line}`;
|
|
143
|
+
const sinkId = idFor(sinkLabel);
|
|
144
|
+
lines.push(` ${sinkId}[["${sinkLabel}"]]`);
|
|
145
|
+
edgeLines.push(` ${srcId} --> ${sinkId}`);
|
|
146
|
+
const color = entry.status === 'prohibited' ? '#c0392b' : '#27ae60';
|
|
147
|
+
styleLines.push(` linkStyle ${edgeIndex} stroke:${color}`);
|
|
148
|
+
edgeIndex++;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
lines.push(...edgeLines, ...styleLines);
|
|
152
|
+
lines.push('```');
|
|
153
|
+
return lines.join('\n');
|
|
154
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// Privacy sink policy (assurance-hardening PRD FR-404).
|
|
2
|
+
//
|
|
3
|
+
// `annotatePrivacyTaint` treats every SINK_PATTERNS match as unconditionally
|
|
4
|
+
// prohibited — any regulated-data class reaching log/response/outboundHttp/
|
|
5
|
+
// etc. always produces a finding, with no way to say "PII flowing to
|
|
6
|
+
// emailSend is expected here (password-reset emails), don't flag it." FR-404
|
|
7
|
+
// asks specifically for a POLICY-gated version of that emission: a finding
|
|
8
|
+
// still fires by default (nothing changes for a repo with no policy file —
|
|
9
|
+
// this is what keeps the PRD's own worked example, "email from request
|
|
10
|
+
// input to logging produces a mapped privacy finding", true unmodified), but
|
|
11
|
+
// an operator can mark a specific (data class, sink) pair — or a sink
|
|
12
|
+
// entirely, for every class — as policy-permitted, which suppresses the
|
|
13
|
+
// finding.
|
|
14
|
+
//
|
|
15
|
+
// Extended for FR-408 ("repository privacy policy defining prohibited and
|
|
16
|
+
// conditionally permitted flows... environment- and destination-specific
|
|
17
|
+
// decisions") with two additional, OPTIONAL rule axes: `environment` (this
|
|
18
|
+
// scan's deployment environment — resolved from AGENTIC_SECURITY_ENVIRONMENT,
|
|
19
|
+
// never from NODE_ENV, which this codebase's own env-hygiene detector
|
|
20
|
+
// already flags as "unreliable as a security boundary" for application
|
|
21
|
+
// code, and the same reasoning applies to a scanner's own policy decisions)
|
|
22
|
+
// and `destination` (a regex matched against the actual sink expression
|
|
23
|
+
// text — "stripe.track", not just the broader "thirdPartySdk" category).
|
|
24
|
+
// Both axes are ADDITIVE constraints and FAIL CLOSED: a rule naming an
|
|
25
|
+
// environment or destination the caller did not supply context for does
|
|
26
|
+
// NOT match — an unknown environment must never silently satisfy an
|
|
27
|
+
// environment-scoped permission, which is the entire point of adding the
|
|
28
|
+
// axis. A rule with neither field set behaves exactly as it did under
|
|
29
|
+
// FR-404 (unconstrained by environment/destination), so every FR-404 rule
|
|
30
|
+
// written before this extension keeps working unmodified.
|
|
31
|
+
//
|
|
32
|
+
// Suppression must be VISIBLE, not silent — same principle the root
|
|
33
|
+
// CLAUDE.md states for ignore-pragma suppressions ("a suppression nobody can
|
|
34
|
+
// see is indistinguishable from a finding that never fired"). A permitted
|
|
35
|
+
// flow is recorded on the annotator's `policyExemptions` array (see
|
|
36
|
+
// privacy-taint.js), never just dropped.
|
|
37
|
+
|
|
38
|
+
import * as fs from 'node:fs';
|
|
39
|
+
import { statePath } from '../posture/state-dir.js';
|
|
40
|
+
|
|
41
|
+
function _policyStatePath(scanRoot) {
|
|
42
|
+
return statePath(scanRoot, 'privacy-policy.json');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Load the operator's privacy sink policy. Never throws — a missing file
|
|
47
|
+
* (ENOENT) is the common "no policy configured" case; a malformed one logs
|
|
48
|
+
* a warning and degrades to the empty policy (everything prohibited, the
|
|
49
|
+
* pre-FR-404 default). Returns { allow: [{sink, class?, reason?}] }.
|
|
50
|
+
*/
|
|
51
|
+
export function loadPrivacySinkPolicy(scanRoot) {
|
|
52
|
+
const EMPTY = { allow: [] };
|
|
53
|
+
if (!scanRoot) return EMPTY;
|
|
54
|
+
const fp = _policyStatePath(scanRoot);
|
|
55
|
+
let raw = null;
|
|
56
|
+
try {
|
|
57
|
+
raw = JSON.parse(fs.readFileSync(fp, 'utf8'));
|
|
58
|
+
} catch (e) {
|
|
59
|
+
if (e.code !== 'ENOENT') {
|
|
60
|
+
console.error(`agentic-security: bad JSON in privacy-policy.json — falling back to no policy (${e.message})`);
|
|
61
|
+
}
|
|
62
|
+
return EMPTY;
|
|
63
|
+
}
|
|
64
|
+
const allow = Array.isArray(raw?.allow)
|
|
65
|
+
? raw.allow.filter(r => r && typeof r.sink === 'string' && r.sink)
|
|
66
|
+
: [];
|
|
67
|
+
return { allow };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// FR-408: does `ruleEnv` (unset | string | string[]) cover `currentEnv`?
|
|
71
|
+
// Unset is unconstrained (matches anything, including an unknown
|
|
72
|
+
// environment — preserves FR-404 rules verbatim). Set-but-unknown-current
|
|
73
|
+
// fails closed: a rule scoped to an environment the caller never told us
|
|
74
|
+
// about must not match by accident.
|
|
75
|
+
function _matchesEnvironment(ruleEnv, currentEnv) {
|
|
76
|
+
if (ruleEnv == null) return true;
|
|
77
|
+
if (!currentEnv) return false;
|
|
78
|
+
const allowed = Array.isArray(ruleEnv) ? ruleEnv : [ruleEnv];
|
|
79
|
+
return allowed.some(e => typeof e === 'string' && e.toLowerCase() === String(currentEnv).toLowerCase());
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// FR-408: does `rulePattern` (unset | regex source string) match `destText`
|
|
83
|
+
// (the raw sink expression, e.g. "stripe.track")? Same fail-closed shape as
|
|
84
|
+
// environment — an invalid operator-supplied regex never silently matches
|
|
85
|
+
// everything, and an unset destText for a destination-scoped rule doesn't
|
|
86
|
+
// match either.
|
|
87
|
+
function _matchesDestination(rulePattern, destText) {
|
|
88
|
+
if (rulePattern == null) return true;
|
|
89
|
+
if (!destText) return false;
|
|
90
|
+
try { return new RegExp(rulePattern, 'i').test(destText); } catch { return false; }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function _rulesForSink(policy, sinkKind, ctx) {
|
|
94
|
+
const allow = Array.isArray(policy?.allow) ? policy.allow : [];
|
|
95
|
+
return allow.filter(r => r.sink === sinkKind
|
|
96
|
+
&& _matchesEnvironment(r.environment, ctx?.environment)
|
|
97
|
+
&& _matchesDestination(r.destination, ctx?.destination));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Is `sinkKind` policy-permitted for every class in `classes`, in the
|
|
102
|
+
* given context (`{environment?, destination?}`, both optional)? A rule
|
|
103
|
+
* matches a class either explicitly (`rule.class === cls`) or by covering
|
|
104
|
+
* the sink for ANY class (`rule.class` unset), AND must satisfy any
|
|
105
|
+
* environment/destination constraint the rule itself declares (FR-408).
|
|
106
|
+
* Returns true only when every matched class has a covering rule — a
|
|
107
|
+
* finding combining a permitted class with an unpermitted one (e.g.
|
|
108
|
+
* "PII+CREDENTIALS both flow to log, but only PII→log was allowed") must
|
|
109
|
+
* still fire, not be silently swept away by a partial allow rule.
|
|
110
|
+
*/
|
|
111
|
+
export function isSinkPermitted(classes, sinkKind, policy, ctx = {}) {
|
|
112
|
+
if (!Array.isArray(classes) || !classes.length) return false;
|
|
113
|
+
const rulesForSink = _rulesForSink(policy, sinkKind, ctx);
|
|
114
|
+
if (!rulesForSink.length) return false;
|
|
115
|
+
return classes.every(cls => rulesForSink.some(r => !r.class || r.class === cls));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The specific rule(s) that permitted a class for a sink in the given
|
|
120
|
+
* context, for exemption disclosure (reason strings, when the operator
|
|
121
|
+
* supplied one).
|
|
122
|
+
*/
|
|
123
|
+
export function permittingRules(classes, sinkKind, policy, ctx = {}) {
|
|
124
|
+
return _rulesForSink(policy, sinkKind, ctx).filter(r => !r.class || classes.includes(r.class));
|
|
125
|
+
}
|
|
@@ -20,45 +20,26 @@
|
|
|
20
20
|
// attempt content classification (Luhn-checking actual values would
|
|
21
21
|
// only catch leaks that have already happened); we classify by NAME
|
|
22
22
|
// + TYPE in declarations.
|
|
23
|
+
//
|
|
24
|
+
// The data-class taxonomy itself (PII/PHI/PCI/FIN/CREDENTIALS/GEOLOCATION/
|
|
25
|
+
// DEVICE_ID, plus any organization-defined classes) is versioned and
|
|
26
|
+
// customizable without editing this file — see ./privacy-taxonomy.js
|
|
27
|
+
// (assurance-hardening PRD FR-402). This module keeps only the sink
|
|
28
|
+
// taxonomy (where regulated data exits), which is not in FR-402's scope.
|
|
29
|
+
//
|
|
30
|
+
// Whether a class-reaches-sink flow actually PRODUCES a finding is
|
|
31
|
+
// policy-gated (assurance-hardening PRD FR-404) — see
|
|
32
|
+
// ./privacy-sink-policy.js. With no policy configured every match is
|
|
33
|
+
// prohibited, unchanged from before FR-404.
|
|
23
34
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
35
|
+
import {
|
|
36
|
+
BUILTIN_TAXONOMY_VERSION, DEFAULT_TAXONOMY, compileTaxonomy,
|
|
37
|
+
loadPrivacyTaxonomy, classifyFieldAgainst, severityForClasses,
|
|
38
|
+
} from './privacy-taxonomy.js';
|
|
39
|
+
import { loadPrivacySinkPolicy, isSinkPermitted, permittingRules } from './privacy-sink-policy.js';
|
|
40
|
+
import { GOVERNANCE_FIELDS, governanceRecordFor } from './privacy-governance.js';
|
|
27
41
|
|
|
28
|
-
const
|
|
29
|
-
PII: [
|
|
30
|
-
/\bfirst[_-]?name\b/i, /\blast[_-]?name\b/i, /\bfull[_-]?name\b/i,
|
|
31
|
-
/\bemail([_-]?address)?\b/i, /\bphone([_-]?number)?\b/i, /\bmobile\b/i,
|
|
32
|
-
/\baddress(?:_?(?:line|street|city|zip|postal))?\b/i,
|
|
33
|
-
/\bdob\b/i, /\bdate[_-]?of[_-]?birth\b/i, /\bbirthday\b/i, /\bbirthdate\b/i,
|
|
34
|
-
/\bage\b/i, /\bgender\b/i, /\bethnicity\b/i, /\brace\b/i, /\bnationality\b/i,
|
|
35
|
-
/\bssn\b/i, /\bsocial[_-]?security/i, /\bnational[_-]?id/i, /\bpassport\b/i,
|
|
36
|
-
/\bdriver[_-]?license\b/i, /\btax[_-]?id\b/i, /\bgovernment[_-]?id\b/i,
|
|
37
|
-
/\bip[_-]?address\b/i, /\bgeo[_-]?location\b/i, /\blatitude\b/i, /\blongitude\b/i,
|
|
38
|
-
],
|
|
39
|
-
PHI: [
|
|
40
|
-
/\b(?:medical|patient|health)[_-]?record\b/i,
|
|
41
|
-
/\bdiagnosis\b/i, /\bcondition\b/i, /\bsymptom\b/i, /\btreatment\b/i,
|
|
42
|
-
/\bmedication\b/i, /\bprescription\b/i, /\bdosage\b/i,
|
|
43
|
-
/\bicd[_-]?(?:9|10|11)\b/i, /\bcpt[_-]?code\b/i, /\bmrn\b/i,
|
|
44
|
-
/\bmedical[_-]?record[_-]?number\b/i, /\bdoctor[_-]?name\b/i,
|
|
45
|
-
/\bphysician\b/i, /\binsurance[_-]?id\b/i, /\bhealth[_-]?plan\b/i,
|
|
46
|
-
],
|
|
47
|
-
PCI: [
|
|
48
|
-
/\bcredit[_-]?card[_-]?(?:number|num|no)?\b/i,
|
|
49
|
-
/\bcard[_-]?(?:number|num|no)\b/i,
|
|
50
|
-
/\b(?:cvc|cvv)2?\b/i, /\bcvc[_-]?code\b/i,
|
|
51
|
-
/\bexp(?:iry|iration)?(?:_?date)?\b/i,
|
|
52
|
-
/\bcardholder[_-]?name\b/i, /\bpan\b/i,
|
|
53
|
-
/\biban\b/i, /\brouting[_-]?number\b/i,
|
|
54
|
-
/\baccount[_-]?number\b/i,
|
|
55
|
-
],
|
|
56
|
-
FIN: [
|
|
57
|
-
/\bsalary\b/i, /\bincome\b/i, /\bbalance\b/i, /\btransaction[_-]?amount\b/i,
|
|
58
|
-
/\bbank[_-]?account\b/i,
|
|
59
|
-
/\bcredit[_-]?score\b/i, /\bnet[_-]?worth\b/i,
|
|
60
|
-
],
|
|
61
|
-
};
|
|
42
|
+
const _BUILTIN_COMPILED = compileTaxonomy(DEFAULT_TAXONOMY);
|
|
62
43
|
|
|
63
44
|
const SINK_PATTERNS = {
|
|
64
45
|
log: /\b(?:log|logger|console|System\.out|System\.err|stdout|stderr|fmt\.Print|print)\b/i,
|
|
@@ -71,18 +52,15 @@ const SINK_PATTERNS = {
|
|
|
71
52
|
};
|
|
72
53
|
|
|
73
54
|
/**
|
|
74
|
-
* Classify a field/variable name into
|
|
55
|
+
* Classify a field/variable name into the built-in taxonomy's buckets
|
|
56
|
+
* (PII / PHI / PCI / FIN / CREDENTIALS / GEOLOCATION / DEVICE_ID).
|
|
75
57
|
* Returns an array of bucket labels (possibly empty, possibly multiple).
|
|
58
|
+
* `compiled` is an optional pre-compiled taxonomy (see
|
|
59
|
+
* privacy-taxonomy.js's compileTaxonomy / loadPrivacyTaxonomy) — omit it
|
|
60
|
+
* to classify against the built-in defaults only.
|
|
76
61
|
*/
|
|
77
|
-
export function classifyField(name) {
|
|
78
|
-
|
|
79
|
-
const out = [];
|
|
80
|
-
for (const [bucket, patterns] of Object.entries(PII_PATTERNS)) {
|
|
81
|
-
for (const p of patterns) {
|
|
82
|
-
if (p.test(name)) { out.push(bucket); break; }
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
return out;
|
|
62
|
+
export function classifyField(name, compiled) {
|
|
63
|
+
return classifyFieldAgainst(name, compiled || _BUILTIN_COMPILED);
|
|
86
64
|
}
|
|
87
65
|
|
|
88
66
|
/**
|
|
@@ -101,19 +79,51 @@ export function classifySink(expr) {
|
|
|
101
79
|
* a privacy-leak finding when a regulated class reaches a non-secure
|
|
102
80
|
* sink (log, response, outbound HTTP, etc.).
|
|
103
81
|
*/
|
|
104
|
-
export function annotatePrivacyTaint(perFileIR) {
|
|
105
|
-
|
|
82
|
+
export function annotatePrivacyTaint(perFileIR, opts = {}) {
|
|
83
|
+
// FR-402 (assurance-hardening PRD): the taxonomy used for classification
|
|
84
|
+
// is resolved once per call, not hardcoded — `opts.compiled` lets a
|
|
85
|
+
// caller (tests, or a future dry-run) hand in an already-compiled
|
|
86
|
+
// taxonomy directly; `opts.scanRoot` loads and compiles the effective
|
|
87
|
+
// (built-in + operator-config) taxonomy from disk. Neither is required:
|
|
88
|
+
// omitting both classifies against the built-in defaults, unchanged from
|
|
89
|
+
// before FR-402.
|
|
90
|
+
const { version, compiled } = opts.compiled
|
|
91
|
+
? { version: opts.taxonomyVersion || BUILTIN_TAXONOMY_VERSION, compiled: opts.compiled }
|
|
92
|
+
: (opts.scanRoot ? loadPrivacyTaxonomy(opts.scanRoot) : { version: BUILTIN_TAXONOMY_VERSION, compiled: _BUILTIN_COMPILED });
|
|
93
|
+
// FR-404 (assurance-hardening PRD): whether a class-reaches-sink flow is
|
|
94
|
+
// PROHIBITED is policy-gated the same way — `opts.sinkPolicy` for a
|
|
95
|
+
// caller that already has one, `opts.scanRoot` to load
|
|
96
|
+
// .agentic-security/privacy-policy.json, or the empty policy (everything
|
|
97
|
+
// prohibited, the pre-FR-404 default) when neither is given.
|
|
98
|
+
const sinkPolicy = opts.sinkPolicy || (opts.scanRoot ? loadPrivacySinkPolicy(opts.scanRoot) : { allow: [] });
|
|
99
|
+
// FR-408: the current deployment environment, ONLY from an explicit
|
|
100
|
+
// caller-supplied opts.environment or AGENTIC_SECURITY_ENVIRONMENT — never
|
|
101
|
+
// NODE_ENV (see privacy-sink-policy.js's header for why). Unset means an
|
|
102
|
+
// environment-scoped allow rule never matches (fail closed), same as
|
|
103
|
+
// before this option existed.
|
|
104
|
+
const policyCtx = { environment: opts.environment || process.env.AGENTIC_SECURITY_ENVIRONMENT || null };
|
|
105
|
+
if (!perFileIR) return { findings: [], piiFields: [], taxonomyVersion: version, policyExemptions: [] };
|
|
106
106
|
const findings = [];
|
|
107
107
|
const piiFields = [];
|
|
108
|
+
// Suppression must be visible, not silent — same principle as the
|
|
109
|
+
// ignore-pragma suppression ledger (root CLAUDE.md): a policy-permitted
|
|
110
|
+
// flow is recorded here, never just dropped.
|
|
111
|
+
const policyExemptions = [];
|
|
108
112
|
for (const [filePath, ir] of (perFileIR instanceof Map ? perFileIR : Object.entries(perFileIR))) {
|
|
109
113
|
if (!ir || !ir._content) continue;
|
|
110
114
|
const lines = ir._content.split('\n');
|
|
111
115
|
// Step 1: collect PII-classified decls.
|
|
112
116
|
const taintedVars = new Map(); // name → array of bucket labels
|
|
117
|
+
// FR-406: declaration line per source variable, so a finding/exemption
|
|
118
|
+
// emitted below can link back to where the regulated data ENTERED
|
|
119
|
+
// (evidence locations must cover both ends of a flow, not just the
|
|
120
|
+
// sink) without re-deriving it from piiFields by proximity guessing.
|
|
121
|
+
const declLineByName = new Map();
|
|
113
122
|
for (const d of ir.decls || []) {
|
|
114
|
-
const classes =
|
|
123
|
+
const classes = classifyFieldAgainst(d.name, compiled);
|
|
115
124
|
if (classes.length) {
|
|
116
125
|
taintedVars.set(d.name, classes);
|
|
126
|
+
declLineByName.set(d.name, d.line);
|
|
117
127
|
piiFields.push({ file: filePath, line: d.line, name: d.name, classes, declaredType: d.type || null });
|
|
118
128
|
}
|
|
119
129
|
}
|
|
@@ -121,26 +131,45 @@ export function annotatePrivacyTaint(perFileIR) {
|
|
|
121
131
|
// reaching a sink.
|
|
122
132
|
for (const call of ir.calls || []) {
|
|
123
133
|
const argText = (call.args || []).map(a => a.text || '').join(',');
|
|
124
|
-
|
|
134
|
+
// FR-408: the raw sink expression text, e.g. "stripe.track" — the
|
|
135
|
+
// destination-matching axis needs the actual call identity, not just
|
|
136
|
+
// its broader sink CATEGORY (sinkLabel), which "stripe.track" and
|
|
137
|
+
// "axios.post" would otherwise share.
|
|
138
|
+
const destText = call.fullPath || call.callee || '';
|
|
139
|
+
const sinkLabel = classifySink(destText);
|
|
125
140
|
if (!sinkLabel) continue;
|
|
141
|
+
const ctx = { ...policyCtx, destination: destText };
|
|
126
142
|
for (const [name, classes] of taintedVars) {
|
|
127
143
|
if (!new RegExp(`\\b${name.replace(/[.+^${}()|\\]/g, '\\$&')}\\b`).test(argText)) continue;
|
|
144
|
+
const sourceLine = declLineByName.get(name) ?? null;
|
|
145
|
+
if (isSinkPermitted(classes, sinkLabel, sinkPolicy, ctx)) {
|
|
146
|
+
policyExemptions.push({
|
|
147
|
+
file: filePath, line: call.line, name, classes, sinkKind: sinkLabel,
|
|
148
|
+
sourceLine,
|
|
149
|
+
rules: permittingRules(classes, sinkLabel, sinkPolicy, ctx).map(r => ({ sink: r.sink, class: r.class || null, reason: r.reason || null, environment: r.environment || null, destination: r.destination || null })),
|
|
150
|
+
});
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
128
153
|
findings.push({
|
|
129
154
|
family: 'pii-exposure',
|
|
130
155
|
subfamily: classes.join('+'),
|
|
131
156
|
file: filePath, line: call.line,
|
|
132
|
-
severity: classes
|
|
157
|
+
severity: severityForClasses(classes, compiled),
|
|
133
158
|
cwe: 'CWE-359', // Exposure of Private Personal Information
|
|
134
159
|
vuln: `Privacy — ${classes.join('+')} data flows to ${sinkLabel} sink`,
|
|
135
160
|
snippet: (lines[call.line - 1] || '').trim().slice(0, 200),
|
|
136
161
|
remediation: `${classes.join(' + ')} data must not flow to ${sinkLabel} unencrypted. Mask, redact, or hash the value before logging / responding / sending to third parties.`,
|
|
137
162
|
piiClass: classes,
|
|
138
163
|
sinkKind: sinkLabel,
|
|
164
|
+
// FR-406: evidence linkage back to the source declaration, not
|
|
165
|
+
// just the sink call site.
|
|
166
|
+
sourceName: name,
|
|
167
|
+
sourceLine,
|
|
139
168
|
});
|
|
140
169
|
}
|
|
141
170
|
}
|
|
142
171
|
}
|
|
143
|
-
return { findings, piiFields };
|
|
172
|
+
return { findings, piiFields, taxonomyVersion: version, policyExemptions };
|
|
144
173
|
}
|
|
145
174
|
|
|
146
175
|
/**
|
|
@@ -167,6 +196,12 @@ export function emitDpiaArtifact(piiFields, findings, opts = {}) {
|
|
|
167
196
|
lines.push('');
|
|
168
197
|
lines.push(`## Data classes identified`);
|
|
169
198
|
lines.push('');
|
|
199
|
+
// FR-407: governance fields (purpose, lawful basis, subject, retention,
|
|
200
|
+
// residency, recipient, transfer, minimization, consent, access,
|
|
201
|
+
// deletion) — none inferable from code, so every one is either
|
|
202
|
+
// operator-supplied (opts.governanceConfig) or explicitly manual_required,
|
|
203
|
+
// never blank and never guessed.
|
|
204
|
+
const governanceConfig = opts.governanceConfig || null;
|
|
170
205
|
for (const [cls, fields] of grouped) {
|
|
171
206
|
lines.push(`### ${cls} (${fields.length} fields)`);
|
|
172
207
|
lines.push('');
|
|
@@ -175,6 +210,14 @@ export function emitDpiaArtifact(piiFields, findings, opts = {}) {
|
|
|
175
210
|
}
|
|
176
211
|
if (fields.length > 20) lines.push(`- … and ${fields.length - 20} more`);
|
|
177
212
|
lines.push('');
|
|
213
|
+
lines.push(`**Governance fields for ${cls}** (see the RoPA artifact for the full register):`);
|
|
214
|
+
lines.push('');
|
|
215
|
+
const record = governanceRecordFor(cls, governanceConfig);
|
|
216
|
+
for (const field of GOVERNANCE_FIELDS) {
|
|
217
|
+
const r = record[field];
|
|
218
|
+
lines.push(`- ${field}: \`${r.value}\`${r.source === 'operator_provided' ? ' (operator-provided)' : ''}`);
|
|
219
|
+
}
|
|
220
|
+
lines.push('');
|
|
178
221
|
}
|
|
179
222
|
lines.push(`## Privacy-related findings`);
|
|
180
223
|
lines.push('');
|
|
@@ -185,6 +228,24 @@ export function emitDpiaArtifact(piiFields, findings, opts = {}) {
|
|
|
185
228
|
}
|
|
186
229
|
if (findings.length > 50) lines.push(`| … | … | … | … and ${findings.length - 50} more |`);
|
|
187
230
|
lines.push('');
|
|
231
|
+
// FR-404: a policy-permitted flow must stay visible here, not vanish —
|
|
232
|
+
// same principle as the ignore-pragma suppression ledger (root
|
|
233
|
+
// CLAUDE.md). Only rendered when the policy actually exempted something.
|
|
234
|
+
const exemptions = Array.isArray(opts.policyExemptions) ? opts.policyExemptions : [];
|
|
235
|
+
if (exemptions.length) {
|
|
236
|
+
lines.push(`## Policy-permitted flows (not flagged above)`);
|
|
237
|
+
lines.push('');
|
|
238
|
+
lines.push(`These flows matched a regulated-data class reaching a sink, but were permitted by .agentic-security/privacy-policy.json and are excluded from the findings table above.`);
|
|
239
|
+
lines.push('');
|
|
240
|
+
lines.push(`| File:Line | Class → Sink | Reason |`);
|
|
241
|
+
lines.push(`|---|---|---|`);
|
|
242
|
+
for (const e of exemptions.slice(0, 50)) {
|
|
243
|
+
const reason = (e.rules || []).map(r => r.reason).filter(Boolean).join('; ') || '(no reason given)';
|
|
244
|
+
lines.push(`| ${e.file}:${e.line} | ${(e.classes || []).join('+')} → ${e.sinkKind} | ${reason} |`);
|
|
245
|
+
}
|
|
246
|
+
if (exemptions.length > 50) lines.push(`| … | … | … and ${exemptions.length - 50} more |`);
|
|
247
|
+
lines.push('');
|
|
248
|
+
}
|
|
188
249
|
lines.push(`## Regulatory framework mapping`);
|
|
189
250
|
lines.push('');
|
|
190
251
|
lines.push(`- **GDPR Art. 35** — DPIA required when processing is likely to result in high risk to data subjects.`);
|
|
@@ -202,4 +263,4 @@ export function emitDpiaArtifact(piiFields, findings, opts = {}) {
|
|
|
202
263
|
return lines.join('\n');
|
|
203
264
|
}
|
|
204
265
|
|
|
205
|
-
export const _internals = {
|
|
266
|
+
export const _internals = { DEFAULT_TAXONOMY, SINK_PATTERNS };
|