@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
package/src/posture/state-dir.js
CHANGED
|
@@ -16,7 +16,12 @@
|
|
|
16
16
|
import * as fs from 'node:fs';
|
|
17
17
|
import * as path from 'node:path';
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
// Exported so a caller that needs the state dir NAME for display purposes
|
|
20
|
+
// (e.g. a human-readable "this file lives at X/Y" string, not an actual
|
|
21
|
+
// path construction) can go through the seam too, rather than hardcoding
|
|
22
|
+
// the literal — see test/no-stray-state.test.js's own detector, which
|
|
23
|
+
// flags a quoted '.agentic-security' literal anywhere else in the tree.
|
|
24
|
+
export const STATE_DIR_NAME = '.agentic-security';
|
|
20
25
|
|
|
21
26
|
const PROJECT_MARKERS = [
|
|
22
27
|
'.git',
|
|
@@ -143,6 +148,36 @@ export function stateWritesEnabled() {
|
|
|
143
148
|
return _stateWritesEnabled;
|
|
144
149
|
}
|
|
145
150
|
|
|
151
|
+
/**
|
|
152
|
+
* Run `fn` with state writes forced off, restoring the PRIOR flag value
|
|
153
|
+
* afterward — for a caller (assurance-hardening PRD FR-704) that must
|
|
154
|
+
* guarantee ITS scan does not mutate the tree, without having to remember
|
|
155
|
+
* to call setStateWritesEnabled(true) again itself. A caller that disables
|
|
156
|
+
* writes and forgets to re-enable them silently breaks every LATER write in
|
|
157
|
+
* the same process — exactly `apply_fix`'s failure mode this wrapper exists
|
|
158
|
+
* to prevent, via `finally` rather than caller discipline.
|
|
159
|
+
*
|
|
160
|
+
* KNOWN LIMITATION: `_stateWritesEnabled` is process-global, not per-call.
|
|
161
|
+
* Two overlapping calls to this function (or one overlapping a direct
|
|
162
|
+
* setStateWritesEnabled() call) can race and leave the flag in the wrong
|
|
163
|
+
* state for one of them once both finish. mcp/CLAUDE.md already documents
|
|
164
|
+
* an accepted concurrency limitation of the same shape for fix-history.js
|
|
165
|
+
* ("concurrent apply_fix calls can race... today benign... a future
|
|
166
|
+
* stateful tool needs serialization") — this is the same class of risk, not
|
|
167
|
+
* a new one, and this wrapper is still a strict improvement over the
|
|
168
|
+
* alternative it replaces (a caller that writes state UNCONDITIONALLY on
|
|
169
|
+
* every call, with no opt-out at all).
|
|
170
|
+
*/
|
|
171
|
+
export async function withStateWritesDisabled(fn) {
|
|
172
|
+
const prior = _stateWritesEnabled;
|
|
173
|
+
_stateWritesEnabled = false;
|
|
174
|
+
try {
|
|
175
|
+
return await fn();
|
|
176
|
+
} finally {
|
|
177
|
+
_stateWritesEnabled = prior;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
146
181
|
// Safe mkdir: only creates .agentic-security/ if the parent has a project marker.
|
|
147
182
|
// Returns the dir on success, null if refused. Logs a warning when refused.
|
|
148
183
|
export function ensureStateDir(scanRoot) {
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// FR-706 (assurance-hardening PRD): manifest-based export and deletion
|
|
2
|
+
// reports — "Operators can prove what was exported, deleted, retained, or
|
|
3
|
+
// failed."
|
|
4
|
+
//
|
|
5
|
+
// Two report kinds, one shared per-item shape ({name, status, ...}), each
|
|
6
|
+
// written by its one real caller in bin/agentic-security.js:
|
|
7
|
+
// - `cmdReset` builds a deletion report from the SAME target/preserved
|
|
8
|
+
// data it already computes for its console output (see FR-702/FR-703),
|
|
9
|
+
// reshaped into a durable, structured artifact instead of only ever
|
|
10
|
+
// printed and lost. Written on BOTH a dry run (items carry status
|
|
11
|
+
// 'planned') and a real run (status 'deleted'/'failed'), so an operator
|
|
12
|
+
// can prove either "what would happen" or "what did happen" depending
|
|
13
|
+
// on which they asked for — `dryRun` on the report says which.
|
|
14
|
+
// - `cmdExport` (new) builds an export report after copying every
|
|
15
|
+
// currently-present registered artifact to an operator-chosen
|
|
16
|
+
// destination, naming what was copied (with a hash) and what failed.
|
|
17
|
+
//
|
|
18
|
+
// Each report is a SINGLE OVERWRITTEN file per kind — the same "last
|
|
19
|
+
// action" precedent as last-scan.json, not an ever-growing log. An
|
|
20
|
+
// operator who needs a persistent audit trail across many runs should
|
|
21
|
+
// retain these externally (commit them, forward to their own SIEM); this
|
|
22
|
+
// module's job is only to make the LAST action's outcome durable and
|
|
23
|
+
// provable, not to be the audit log itself.
|
|
24
|
+
|
|
25
|
+
import { statePath, safeWriteState } from './state-dir.js';
|
|
26
|
+
|
|
27
|
+
export const DELETION_REPORT_FILE = 'deletion-report.json';
|
|
28
|
+
export const EXPORT_REPORT_FILE = 'export-report.json';
|
|
29
|
+
|
|
30
|
+
export function buildDeletionReport({ mode, dryRun, root, items, preserved }) {
|
|
31
|
+
const list = Array.isArray(items) ? items : [];
|
|
32
|
+
const preservedList = Array.isArray(preserved) ? preserved : [];
|
|
33
|
+
return {
|
|
34
|
+
schema: 'agentic-security/deletion-report@1',
|
|
35
|
+
generatedAt: new Date().toISOString(),
|
|
36
|
+
mode: mode || 'reset',
|
|
37
|
+
dryRun: !!dryRun,
|
|
38
|
+
root: root || null,
|
|
39
|
+
items: list,
|
|
40
|
+
preserved: preservedList,
|
|
41
|
+
summary: {
|
|
42
|
+
planned: list.filter(i => i.status === 'planned').length,
|
|
43
|
+
deleted: list.filter(i => i.status === 'deleted').length,
|
|
44
|
+
failed: list.filter(i => i.status === 'failed').length,
|
|
45
|
+
preserved: preservedList.length,
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function writeDeletionReport(scanRoot, report) {
|
|
51
|
+
try {
|
|
52
|
+
const fp = statePath(scanRoot, DELETION_REPORT_FILE);
|
|
53
|
+
return safeWriteState(fp, JSON.stringify(report, null, 2) + '\n') ? fp : null;
|
|
54
|
+
} catch { return null; }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function buildExportReport({ root, outDir, items }) {
|
|
58
|
+
const list = Array.isArray(items) ? items : [];
|
|
59
|
+
return {
|
|
60
|
+
schema: 'agentic-security/export-report@1',
|
|
61
|
+
generatedAt: new Date().toISOString(),
|
|
62
|
+
root: root || null,
|
|
63
|
+
outDir: outDir || null,
|
|
64
|
+
items: list,
|
|
65
|
+
summary: {
|
|
66
|
+
exported: list.filter(i => i.status === 'exported').length,
|
|
67
|
+
failed: list.filter(i => i.status === 'failed').length,
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function writeExportReport(scanRoot, report) {
|
|
73
|
+
try {
|
|
74
|
+
const fp = statePath(scanRoot, EXPORT_REPORT_FILE);
|
|
75
|
+
return safeWriteState(fp, JSON.stringify(report, null, 2) + '\n') ? fp : null;
|
|
76
|
+
} catch { return null; }
|
|
77
|
+
}
|
|
@@ -4,11 +4,41 @@
|
|
|
4
4
|
// reviewer + expiry + rule-version pin)
|
|
5
5
|
// One function `applySuppressions(findings, scanRoot, profile)` filters in
|
|
6
6
|
// place. Loaders accept malformed input gracefully (skip bad entries, log).
|
|
7
|
+
//
|
|
8
|
+
// FR-1004 (assurance-hardening PRD): "exception owner, reason, scope,
|
|
9
|
+
// compensating control, and expiry." The pro schema already had reason
|
|
10
|
+
// (`reason`) and expiry (`expires_at`); `owner`, `scope`, and
|
|
11
|
+
// `compensating_control` are added here as required fields on the SAME
|
|
12
|
+
// entry, validated by the SAME `validateProSuppression` gate that already
|
|
13
|
+
// enforces the two-person rule. This is a deliberate tightening: an
|
|
14
|
+
// existing suppressions.yml entry written before this field set was
|
|
15
|
+
// required will now fail validation and REOPEN its finding, same as any
|
|
16
|
+
// other invalid pro suppression already does — an operator must add the
|
|
17
|
+
// three new fields to keep the exception honored. `owner` is who is
|
|
18
|
+
// accountable for eventually resolving or renewing the exception (may or
|
|
19
|
+
// may not be the same person as `reviewer`); `scope` is a short,
|
|
20
|
+
// human-readable statement of what the exception covers (this module's
|
|
21
|
+
// matching remains per-finding — `scope` documents intent, it does not
|
|
22
|
+
// widen what gets suppressed); `compensating_control` is the specific
|
|
23
|
+
// alternative mitigation that justifies leaving the underlying finding
|
|
24
|
+
// unfixed (a required field, not inferred from `reason` — "we'll fix it
|
|
25
|
+
// later" is a reason, not a compensating control, and conflating the two
|
|
26
|
+
// was the actual gap this PRD item exists to close).
|
|
27
|
+
//
|
|
28
|
+
// "Expired exceptions automatically reopen findings or fail the gate":
|
|
29
|
+
// `applySuppressions` already returns an expired suppression's finding to
|
|
30
|
+
// the KEPT (open) set rather than the suppressed set (see the `exp < now`
|
|
31
|
+
// branch below) — an expired exception was always self-reopening by
|
|
32
|
+
// construction, this was just never covered by a test until FR-1004.
|
|
33
|
+
// Reopening feeds the existing severity-based CI gate (`--fail-on`)
|
|
34
|
+
// exactly like any other open finding; no second, parallel expiry-gate
|
|
35
|
+
// mechanism is needed or built here.
|
|
7
36
|
|
|
8
37
|
import * as fs from 'node:fs';
|
|
9
38
|
import * as path from 'node:path';
|
|
10
39
|
import * as yaml from '../util/yaml.js';
|
|
11
40
|
import { statePath, safeWriteState } from './state-dir.js';
|
|
41
|
+
import { loadApproverRegistry, verifyApprover, requiredRolesFor } from '../fix/approver-registry.js';
|
|
12
42
|
|
|
13
43
|
const MS_PER_DAY = 86400000;
|
|
14
44
|
const SOFT_TTL_DAYS = 30;
|
|
@@ -59,14 +89,31 @@ export function loadProSuppressions(scanRoot) {
|
|
|
59
89
|
}
|
|
60
90
|
|
|
61
91
|
// Validate one entry. Returns { ok, errors }.
|
|
62
|
-
|
|
92
|
+
//
|
|
93
|
+
// FR-1002: "bind approvals, EXCEPTIONS, AND SUPPRESSIONS to verified
|
|
94
|
+
// identities and roles... anonymous or unauthorized high-risk exceptions
|
|
95
|
+
// fail policy." A pro suppression's `justification_signed_by` is exactly
|
|
96
|
+
// such an exception-approval identity, but until this fix it was pure
|
|
97
|
+
// self-reported free text -- FR-307/FR-1002's registry existed and was
|
|
98
|
+
// wired into apply_fix, but never consulted here. `opts.registry` is
|
|
99
|
+
// optional and NO-OP when absent (loadApproverRegistry(scanRoot) returns
|
|
100
|
+
// null until an operator opts in, matching every other gate in this
|
|
101
|
+
// registry), so an existing project with no registry file sees zero
|
|
102
|
+
// behavior change. `opts.requiredRoles` lets the caller scope role
|
|
103
|
+
// requirements to the SUPPRESSED FINDING's own family/category, the same
|
|
104
|
+
// way FR-307 scopes them to a change's material-change category.
|
|
105
|
+
export function validateProSuppression(entry, opts = {}) {
|
|
63
106
|
const errors = [];
|
|
64
|
-
for (const k of ['finding_id', 'file', 'reason', 'justification_signed_by', 'reviewer', 'expires_at']) {
|
|
107
|
+
for (const k of ['finding_id', 'file', 'reason', 'justification_signed_by', 'reviewer', 'expires_at', 'owner', 'scope', 'compensating_control']) {
|
|
65
108
|
if (!entry[k] || (typeof entry[k] === 'string' && !entry[k].trim())) errors.push(`missing: ${k}`);
|
|
66
109
|
}
|
|
67
110
|
if (entry.justification_signed_by && entry.reviewer && entry.justification_signed_by === entry.reviewer) {
|
|
68
111
|
errors.push('justification_signed_by must differ from reviewer (two-person rule)');
|
|
69
112
|
}
|
|
113
|
+
if (entry.justification_signed_by && opts.registry !== undefined) {
|
|
114
|
+
const v = verifyApprover(opts.registry, entry.justification_signed_by, opts.requiredRoles || []);
|
|
115
|
+
if (!v.verified) errors.push(`justification_signed_by not authorized: ${v.reason}`);
|
|
116
|
+
}
|
|
70
117
|
if (entry.expires_at) {
|
|
71
118
|
const t = _dateOnly(entry.expires_at);
|
|
72
119
|
if (!Number.isFinite(t)) errors.push('expires_at must be ISO date');
|
|
@@ -84,6 +131,11 @@ export function applySuppressions(findings, scanRoot, profile) {
|
|
|
84
131
|
const items = isPro ? loadProSuppressions(scanRoot) : loadSoftAccepted(scanRoot);
|
|
85
132
|
if (!items.length) return findings;
|
|
86
133
|
|
|
134
|
+
// FR-1002: loaded once per call, not per entry — a missing registry file
|
|
135
|
+
// (the common, not-opted-in case) makes verifyApprover() a no-op below,
|
|
136
|
+
// exactly like every other gate built on this registry.
|
|
137
|
+
const approverRegistry = isPro ? loadApproverRegistry(scanRoot) : null;
|
|
138
|
+
|
|
87
139
|
const now = _now();
|
|
88
140
|
const kept = [];
|
|
89
141
|
const suppressed = [];
|
|
@@ -106,7 +158,11 @@ export function applySuppressions(findings, scanRoot, profile) {
|
|
|
106
158
|
}
|
|
107
159
|
// Pro: validate the entry still passes
|
|
108
160
|
if (isPro) {
|
|
109
|
-
|
|
161
|
+
// Same registry/category shape FR-307 already uses for high-impact
|
|
162
|
+
// fixes: an operator opts a finding family into role-gating via
|
|
163
|
+
// requiredRolesByCategory, keyed by that family's name.
|
|
164
|
+
const requiredRoles = requiredRolesFor(approverRegistry, [f.family].filter(Boolean));
|
|
165
|
+
const v = validateProSuppression({ ...matched, severity: f.severity }, { registry: approverRegistry, requiredRoles });
|
|
110
166
|
if (!v.ok) { kept.push({ ...f, _suppressionInvalid: v.errors }); continue; }
|
|
111
167
|
}
|
|
112
168
|
suppressed.push({ ...f, _suppressed: matched });
|
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
// Privacy IR adapter (assurance-hardening PRD, Milestone 2, FR-401).
|
|
2
|
+
//
|
|
3
|
+
// dataflow/privacy-taint.js's annotatePrivacyTaint() expects a per-file map
|
|
4
|
+
// of `{ _content, decls: [{name, line, type}], calls: [{line, fullPath|callee,
|
|
5
|
+
// args: [{text}]}] }` — a FLAT, per-file shape. The real Layer-1 IR
|
|
6
|
+
// (ir/index.js#buildProjectIR) does not produce that shape at all: it is
|
|
7
|
+
// per-FUNCTION (`functions: [{params, cfg: {nodes: {...}}}]`), and CFG node
|
|
8
|
+
// arguments are structured expression trees (`{kind:'ident', name}`,
|
|
9
|
+
// `{kind:'member', object, prop}`, ...), not `{text: string}` objects. This
|
|
10
|
+
// was the actual reason privacy analysis was wired with hardcoded
|
|
11
|
+
// `decls:[], calls:[]` (A-06) — there was no adapter reconciling the two
|
|
12
|
+
// shapes, not a missing capability in the underlying IR/taint engine (see
|
|
13
|
+
// docs/implementation/assurance-hardening-decisions.md D-0003's pattern:
|
|
14
|
+
// verify before assuming something is unbuilt).
|
|
15
|
+
//
|
|
16
|
+
// This module is that adapter. It does NOT reimplement privacy analysis —
|
|
17
|
+
// it only reshapes the real IR into exactly what annotatePrivacyTaint()
|
|
18
|
+
// already knows how to consume.
|
|
19
|
+
//
|
|
20
|
+
// Declarations: sourced from every function's `params` (a parameter is a
|
|
21
|
+
// declaration) and every `assign`-kind CFG node's `target` (an assignment
|
|
22
|
+
// declares/rebinds a name). Both are what a real codebase would call PII
|
|
23
|
+
// "at the point it enters a function or gets bound to a local".
|
|
24
|
+
//
|
|
25
|
+
// Calls: sourced from every `call`-kind CFG node, AND from `assign`-kind
|
|
26
|
+
// nodes whose `source` is itself a call expression (`const x = fetch(...)`)
|
|
27
|
+
// — privacy-taint.js only walks a flat `ir.calls` list, so a call used as
|
|
28
|
+
// an assignment's right-hand side needs to be surfaced there too or it's
|
|
29
|
+
// invisible to the sink-matching pass entirely.
|
|
30
|
+
//
|
|
31
|
+
// Argument "text": privacy-taint.js's sink-matching does a literal
|
|
32
|
+
// \b<name>\b regex test against the joined text of a call's argument
|
|
33
|
+
// expressions — it does not need faithful re-rendered source code, only
|
|
34
|
+
// text that CONTAINS every identifier name referenced (directly or nested)
|
|
35
|
+
// in that argument, so a tainted variable's name is found wherever it
|
|
36
|
+
// appears. `_identifiersIn` recursively collects identifier names (and
|
|
37
|
+
// literal values, for completeness) rather than attempting full expression
|
|
38
|
+
// pretty-printing, which is simpler and sufficient for this consumer's
|
|
39
|
+
// actual matching logic.
|
|
40
|
+
//
|
|
41
|
+
// Storage: this codebase already has a separate stored-taint subsystem
|
|
42
|
+
// (engine.js's STORED_TAINT_FIELD_PATTERNS / buildStoredTaintRegistry) that
|
|
43
|
+
// tracks which ORM/model fields are written with unsanitized request input,
|
|
44
|
+
// for its own cross-file write-then-render correlation (crossStoredTaint).
|
|
45
|
+
// `_storageForFile` reindexes that ALREADY-COMPUTED, whole-project registry
|
|
46
|
+
// down to one file's writes rather than recomputing anything — the engine
|
|
47
|
+
// builds it once per scan regardless of whether privacy analysis runs at all.
|
|
48
|
+
//
|
|
49
|
+
// Types: the underlying Layer-1 IR (ir/index.js#buildProjectIR) tracks no
|
|
50
|
+
// type-annotation/inference data for any language — genuine cross-language
|
|
51
|
+
// type inference would mean extending every language parser, a separate,
|
|
52
|
+
// substantially larger project deliberately out of scope here. What IS in
|
|
53
|
+
// scope, and implemented below, is a narrow, honest, TypeScript-only sliver:
|
|
54
|
+
// `_extractTsTypes` does a SEPARATE, read-only `@babel/core` `parseSync` pass
|
|
55
|
+
// over `.ts`/`.tsx` source (never touching ir/parser-js.js's own transform
|
|
56
|
+
// pipeline or its shared, heavily-relied-on visitor plugin) and walks the
|
|
57
|
+
// resulting AST for `TSTypeAnnotation` nodes on function parameters and
|
|
58
|
+
// `let`/`const`/`var` declarators. Babel's `parseSync` only PARSES — it never
|
|
59
|
+
// runs `@babel/preset-typescript`'s stripping transform, so a `.ts` file's
|
|
60
|
+
// type annotations are fully intact on the AST it returns, no plugin-ordering
|
|
61
|
+
// trick required. Matching this SEPARATE parse's declarations back to the
|
|
62
|
+
// real IR's decls is done by `(line, name)` — the same real source line and
|
|
63
|
+
// identifier name a given parameter or declarator sits on in both parses,
|
|
64
|
+
// since both parse the identical source text. A `.js` file, a type Babel
|
|
65
|
+
// cannot express as a plain name (destructured/computed types, deeply
|
|
66
|
+
// generic types past a small recursion depth), or any parse/traverse failure
|
|
67
|
+
// all degrade to `type: null` — never a guess, matching every other
|
|
68
|
+
// optional field in this module.
|
|
69
|
+
|
|
70
|
+
import { parseSync as babelParseSync, traverse as babelTraverse } from '@babel/core';
|
|
71
|
+
import presetTypescript from '@babel/preset-typescript';
|
|
72
|
+
|
|
73
|
+
const MAX_EXPR_DEPTH = 40;
|
|
74
|
+
const MAX_TS_TYPE_DEPTH = 6;
|
|
75
|
+
|
|
76
|
+
function _calleeToString(callee, depth = 0) {
|
|
77
|
+
if (typeof callee === 'string') return callee;
|
|
78
|
+
if (!callee || typeof callee !== 'object' || depth > MAX_EXPR_DEPTH) return '';
|
|
79
|
+
switch (callee.kind) {
|
|
80
|
+
case 'ident': return callee.name || '';
|
|
81
|
+
case 'member': {
|
|
82
|
+
const obj = _calleeToString(callee.object, depth + 1);
|
|
83
|
+
return obj ? `${obj}.${callee.prop || ''}` : String(callee.prop || '');
|
|
84
|
+
}
|
|
85
|
+
case 'call': return _calleeToString(callee.callee, depth + 1);
|
|
86
|
+
default: return '';
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function _identifiersIn(expr, out, depth = 0) {
|
|
91
|
+
if (!expr || typeof expr !== 'object' || depth > MAX_EXPR_DEPTH) return;
|
|
92
|
+
switch (expr.kind) {
|
|
93
|
+
case 'ident':
|
|
94
|
+
if (expr.name) out.push(expr.name);
|
|
95
|
+
return;
|
|
96
|
+
case 'literal':
|
|
97
|
+
if (expr.value != null) out.push(String(expr.value));
|
|
98
|
+
return;
|
|
99
|
+
case 'member':
|
|
100
|
+
_identifiersIn(expr.object, out, depth + 1);
|
|
101
|
+
if (expr.prop) out.push(String(expr.prop));
|
|
102
|
+
return;
|
|
103
|
+
case 'binary':
|
|
104
|
+
case 'logical':
|
|
105
|
+
_identifiersIn(expr.left, out, depth + 1);
|
|
106
|
+
_identifiersIn(expr.right, out, depth + 1);
|
|
107
|
+
return;
|
|
108
|
+
case 'tpl':
|
|
109
|
+
for (const p of expr.parts || []) _identifiersIn(p, out, depth + 1);
|
|
110
|
+
return;
|
|
111
|
+
case 'call':
|
|
112
|
+
_identifiersIn(expr.callee, out, depth + 1);
|
|
113
|
+
for (const a of expr.args || []) _identifiersIn(a, out, depth + 1);
|
|
114
|
+
return;
|
|
115
|
+
case 'array':
|
|
116
|
+
for (const e of expr.elements || []) _identifiersIn(e, out, depth + 1);
|
|
117
|
+
return;
|
|
118
|
+
case 'object':
|
|
119
|
+
for (const p of expr.props || []) _identifiersIn(p.value, out, depth + 1);
|
|
120
|
+
return;
|
|
121
|
+
case 'union':
|
|
122
|
+
for (const b of expr.branches || []) _identifiersIn(b, out, depth + 1);
|
|
123
|
+
return;
|
|
124
|
+
default:
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function _argsToTextArgs(args) {
|
|
130
|
+
return (args || []).map(a => {
|
|
131
|
+
const names = [];
|
|
132
|
+
_identifiersIn(a, names);
|
|
133
|
+
return { text: names.join(' ') };
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// A TSQualifiedName is `A.B.C` (e.g. `Foo.Bar`) — dot-join it the same way
|
|
138
|
+
// _calleeToString dot-joins a member expression above.
|
|
139
|
+
function _qualifiedTypeName(node, depth = 0) {
|
|
140
|
+
if (!node || depth > MAX_TS_TYPE_DEPTH) return null;
|
|
141
|
+
if (node.type === 'Identifier') return node.name || null;
|
|
142
|
+
if (node.type === 'TSQualifiedName') {
|
|
143
|
+
const left = _qualifiedTypeName(node.left, depth + 1);
|
|
144
|
+
const right = node.right?.name || null;
|
|
145
|
+
if (!left || !right) return left || right;
|
|
146
|
+
return `${left}.${right}`;
|
|
147
|
+
}
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Renders a TSType AST node to a short, human-readable string — never a
|
|
153
|
+
* best-effort re-render of the whole type, just enough to be a real,
|
|
154
|
+
* non-fabricated signal. Anything not recognized (mapped types, conditional
|
|
155
|
+
* types, indexed access, deeply nested generics past the depth guard) is
|
|
156
|
+
* `null`, exactly like every other "don't know" in this module — no type
|
|
157
|
+
* string is ever invented for a shape this doesn't genuinely understand.
|
|
158
|
+
*/
|
|
159
|
+
function _tsTypeToString(node, depth = 0) {
|
|
160
|
+
if (!node || depth > MAX_TS_TYPE_DEPTH) return null;
|
|
161
|
+
switch (node.type) {
|
|
162
|
+
case 'TSStringKeyword': return 'string';
|
|
163
|
+
case 'TSNumberKeyword': return 'number';
|
|
164
|
+
case 'TSBooleanKeyword': return 'boolean';
|
|
165
|
+
case 'TSAnyKeyword': return 'any';
|
|
166
|
+
case 'TSUnknownKeyword': return 'unknown';
|
|
167
|
+
case 'TSVoidKeyword': return 'void';
|
|
168
|
+
case 'TSNullKeyword': return 'null';
|
|
169
|
+
case 'TSUndefinedKeyword': return 'undefined';
|
|
170
|
+
case 'TSNeverKeyword': return 'never';
|
|
171
|
+
case 'TSObjectKeyword': return 'object';
|
|
172
|
+
case 'TSBigIntKeyword': return 'bigint';
|
|
173
|
+
case 'TSSymbolKeyword': return 'symbol';
|
|
174
|
+
case 'TSTypeReference': {
|
|
175
|
+
const name = node.typeName?.type === 'Identifier'
|
|
176
|
+
? node.typeName.name
|
|
177
|
+
: _qualifiedTypeName(node.typeName, depth + 1);
|
|
178
|
+
if (!name) return null;
|
|
179
|
+
// Babel's current AST names this `typeArguments`; `typeParameters` was
|
|
180
|
+
// the property on older @babel/parser versions for the same node —
|
|
181
|
+
// checking both means this doesn't silently go generic-blind on a
|
|
182
|
+
// dependency bump either direction.
|
|
183
|
+
const paramNodes = (node.typeArguments || node.typeParameters)?.params || [];
|
|
184
|
+
const args = paramNodes.map(p => _tsTypeToString(p, depth + 1)).filter(Boolean);
|
|
185
|
+
return (args.length && args.length === paramNodes.length) ? `${name}<${args.join(', ')}>` : name;
|
|
186
|
+
}
|
|
187
|
+
case 'TSArrayType': {
|
|
188
|
+
const el = _tsTypeToString(node.elementType, depth + 1);
|
|
189
|
+
return el ? `${el}[]` : null;
|
|
190
|
+
}
|
|
191
|
+
case 'TSUnionType': {
|
|
192
|
+
const parts = (node.types || []).map(t => _tsTypeToString(t, depth + 1)).filter(Boolean);
|
|
193
|
+
return parts.length === (node.types || []).length ? parts.join(' | ') : null;
|
|
194
|
+
}
|
|
195
|
+
case 'TSLiteralType': {
|
|
196
|
+
const lit = node.literal;
|
|
197
|
+
if (lit?.type === 'StringLiteral') return JSON.stringify(lit.value);
|
|
198
|
+
if (lit?.type === 'NumericLiteral') return String(lit.value);
|
|
199
|
+
if (lit?.type === 'BooleanLiteral') return String(lit.value);
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
default: return null;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* FR-401's "types" element, TypeScript-only. A separate, read-only parse of
|
|
208
|
+
* the real source — never the transformed AST ir/parser-js.js builds its IR
|
|
209
|
+
* from, and never a mutation of anything that module owns. `parseSync` only
|
|
210
|
+
* parses; `@babel/preset-typescript`'s stripping transform never runs, so
|
|
211
|
+
* `.ts`/`.tsx` type annotations are fully intact on the returned AST.
|
|
212
|
+
*
|
|
213
|
+
* Keyed by the real source line a parameter or declarator's IDENTIFIER sits
|
|
214
|
+
* on (matching how ir/parser-js.js's own IR already records a parameter's
|
|
215
|
+
* line as its enclosing function's start line, and an assignment's line as
|
|
216
|
+
* its own CFG node's line) so `_adaptFile` can look a type up by the exact
|
|
217
|
+
* (line, name) pair it already has, without needing any cross-parse node
|
|
218
|
+
* identity.
|
|
219
|
+
*
|
|
220
|
+
* @returns {Map<number, Map<string,string>>} line -> (name -> type string)
|
|
221
|
+
*/
|
|
222
|
+
function _extractTsTypes(file, content) {
|
|
223
|
+
const out = new Map();
|
|
224
|
+
if (!/\.tsx?$/i.test(file) || typeof content !== 'string' || content.length > 500_000) return out;
|
|
225
|
+
const record = (name, line, typeAnnotationNode) => {
|
|
226
|
+
if (!name || !line || !typeAnnotationNode) return;
|
|
227
|
+
const t = _tsTypeToString(typeAnnotationNode.typeAnnotation);
|
|
228
|
+
if (!t) return;
|
|
229
|
+
if (!out.has(line)) out.set(line, new Map());
|
|
230
|
+
out.get(line).set(name, t);
|
|
231
|
+
};
|
|
232
|
+
let ast;
|
|
233
|
+
try {
|
|
234
|
+
ast = babelParseSync(content, {
|
|
235
|
+
filename: file,
|
|
236
|
+
presets: [[presetTypescript, { ignoreExtensions: true }]],
|
|
237
|
+
// Mirrors ir/parser-js.js's own parserOpts exactly — accepting the same
|
|
238
|
+
// decorator syntax it accepts, so this separate parse does not reject
|
|
239
|
+
// (and silently lose types from) a file the real IR parser accepts.
|
|
240
|
+
parserOpts: { plugins: ['decorators-legacy', 'decoratorAutoAccessors'] },
|
|
241
|
+
babelrc: false, configFile: false, ast: true, code: false,
|
|
242
|
+
});
|
|
243
|
+
} catch { return out; }
|
|
244
|
+
if (!ast) return out;
|
|
245
|
+
try {
|
|
246
|
+
babelTraverse(ast, {
|
|
247
|
+
Function(path) {
|
|
248
|
+
for (const p of path.node.params || []) {
|
|
249
|
+
const resolved = p.type === 'AssignmentPattern' ? p.left : p;
|
|
250
|
+
if (resolved?.type === 'Identifier' && resolved.typeAnnotation) {
|
|
251
|
+
record(resolved.name, path.node.loc?.start?.line, resolved.typeAnnotation);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
},
|
|
255
|
+
VariableDeclarator(path) {
|
|
256
|
+
const id = path.node.id;
|
|
257
|
+
if (id?.type === 'Identifier' && id.typeAnnotation) {
|
|
258
|
+
record(id.name, path.node.loc?.start?.line, id.typeAnnotation);
|
|
259
|
+
}
|
|
260
|
+
},
|
|
261
|
+
});
|
|
262
|
+
} catch { /* degrade to whatever was recorded before the failure */ }
|
|
263
|
+
return out;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* FR-401's "storage" element. Reindexes the whole-project, field-name-keyed
|
|
268
|
+
* stored-taint registry (engine.js#buildStoredTaintRegistry — already
|
|
269
|
+
* computed once per scan for crossStoredTaint's own cross-file correlation,
|
|
270
|
+
* not recomputed here) down to just the writes that happened in THIS file.
|
|
271
|
+
* `storedRegistry` is `{ [fieldName]: [{file, line, snippet, named}, ...] }`;
|
|
272
|
+
* this returns the flattened, per-file subset as
|
|
273
|
+
* `[{field, line, snippet, named}, ...]`.
|
|
274
|
+
*/
|
|
275
|
+
function _storageForFile(file, storedRegistry) {
|
|
276
|
+
const out = [];
|
|
277
|
+
if (!storedRegistry || typeof storedRegistry !== 'object') return out;
|
|
278
|
+
for (const [field, writes] of Object.entries(storedRegistry)) {
|
|
279
|
+
for (const w of writes || []) {
|
|
280
|
+
if (w && w.file === file) out.push({ field, line: w.line ?? 0, snippet: w.snippet || '', named: !!w.named });
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
return out;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* @param {object} fileIR - one entry from ir/index.js#buildProjectIR's `perFile` map: {file, functions, topLevel}
|
|
288
|
+
* @param {Map<number, Map<string,string>>|null} [tsTypes] - `_extractTsTypes`'s
|
|
289
|
+
* output for this same file. Optional: omitted (or no match at a given
|
|
290
|
+
* (line, name)) leaves `type: null`, exactly as before this existed.
|
|
291
|
+
* @returns {{decls: Array, calls: Array}}
|
|
292
|
+
*/
|
|
293
|
+
function _adaptFile(fileIR, tsTypes) {
|
|
294
|
+
const decls = [];
|
|
295
|
+
const calls = [];
|
|
296
|
+
const returns = [];
|
|
297
|
+
const typeAt = (line, name) => tsTypes?.get(line)?.get(name) ?? null;
|
|
298
|
+
for (const fn of fileIR?.functions || []) {
|
|
299
|
+
for (const paramName of fn.params || []) {
|
|
300
|
+
if (typeof paramName === 'string' && paramName) {
|
|
301
|
+
// `kind` genuinely distinguishes a parameter from a later
|
|
302
|
+
// assignment (FR-401's "parameters" vs "assignments" elements) —
|
|
303
|
+
// annotatePrivacyTaint() ignores unknown fields, so this is purely
|
|
304
|
+
// additive for that consumer.
|
|
305
|
+
decls.push({ name: paramName, line: fn.line ?? 0, type: typeAt(fn.line ?? 0, paramName), kind: 'parameter' });
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
const nodes = fn.cfg?.nodes || {};
|
|
309
|
+
for (const node of Object.values(nodes)) {
|
|
310
|
+
if (!node) continue;
|
|
311
|
+
if (node.kind === 'assign' && node.target) {
|
|
312
|
+
const line = node.line ?? fn.line ?? 0;
|
|
313
|
+
decls.push({ name: node.target, line, type: typeAt(line, node.target), kind: 'assignment' });
|
|
314
|
+
}
|
|
315
|
+
if (node.kind === 'call') {
|
|
316
|
+
calls.push({
|
|
317
|
+
line: node.line ?? fn.line ?? 0,
|
|
318
|
+
fullPath: _calleeToString(node.callee),
|
|
319
|
+
args: _argsToTextArgs(node.args),
|
|
320
|
+
});
|
|
321
|
+
} else if (node.kind === 'assign' && node.source && node.source.kind === 'call') {
|
|
322
|
+
calls.push({
|
|
323
|
+
line: node.line ?? fn.line ?? 0,
|
|
324
|
+
fullPath: _calleeToString(node.source.callee),
|
|
325
|
+
args: _argsToTextArgs(node.source.args),
|
|
326
|
+
});
|
|
327
|
+
} else if (node.kind === 'return' && node.value) {
|
|
328
|
+
// FR-401's "returns" element: every CFG language parser genuinely
|
|
329
|
+
// emits return-kind nodes (confirmed across parser-js/py/java/go/
|
|
330
|
+
// rb/php/cs/kt/cpp), so this is real IR data, not a stub — surfaced
|
|
331
|
+
// here even though annotatePrivacyTaint() (the shallow walker) does
|
|
332
|
+
// not consume it today, matching the adapter's stated job of
|
|
333
|
+
// supplying what the real IR has, not just what today's one
|
|
334
|
+
// consumer happens to read (see D-0052's evidence for the honest
|
|
335
|
+
// caveat: no current caller reads this field yet).
|
|
336
|
+
const names = [];
|
|
337
|
+
_identifiersIn(node.value, names);
|
|
338
|
+
returns.push({ line: node.line ?? fn.line ?? 0, names });
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
return { decls, calls, returns };
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Build the flat, per-file map annotatePrivacyTaint() expects, from the real
|
|
347
|
+
* Layer-1 IR plus the raw file contents (for the `_content` field it uses
|
|
348
|
+
* for snippet extraction — the IR itself carries no raw source text).
|
|
349
|
+
*
|
|
350
|
+
* @param {Record<string,object>} perFileIR - ir/index.js#buildProjectIR's `perFile` (or buildProjectIRAsync's)
|
|
351
|
+
* @param {Record<string,string>} fileContents
|
|
352
|
+
* @param {object|null} [storedRegistry] - engine.js#buildStoredTaintRegistry's
|
|
353
|
+
* output, for FR-401's "storage" element. Optional and additive: omitted
|
|
354
|
+
* (or not an object) yields `storage: []` for every file, same
|
|
355
|
+
* degrade-gracefully convention as every other field here — this is not a
|
|
356
|
+
* second copy of the registry's own computation, just a per-file view of
|
|
357
|
+
* the one the engine already builds once per scan.
|
|
358
|
+
* @returns {Map<string, {_content:string, decls:Array, calls:Array, returns:Array, storage:Array}>}
|
|
359
|
+
*/
|
|
360
|
+
export function adaptIRForPrivacyTaint(perFileIR, fileContents, storedRegistry) {
|
|
361
|
+
const out = new Map();
|
|
362
|
+
const fc = fileContents || {};
|
|
363
|
+
for (const [file, content] of Object.entries(fc)) {
|
|
364
|
+
if (typeof content !== 'string') continue;
|
|
365
|
+
const fileIR = perFileIR ? perFileIR[file] : null;
|
|
366
|
+
// FR-401's "types" element: a real, TypeScript-only signal via a
|
|
367
|
+
// separate parse (see _extractTsTypes's own header) — a no-op Map for
|
|
368
|
+
// every non-.ts/.tsx file, so this costs nothing on the common path.
|
|
369
|
+
const tsTypes = _extractTsTypes(file, content);
|
|
370
|
+
const { decls, calls, returns } = fileIR ? _adaptFile(fileIR, tsTypes) : { decls: [], calls: [], returns: [] };
|
|
371
|
+
const storage = _storageForFile(file, storedRegistry);
|
|
372
|
+
out.set(file, { _content: content, decls, calls, returns, storage });
|
|
373
|
+
}
|
|
374
|
+
return out;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
export const _internals = {
|
|
378
|
+
_calleeToString, _identifiersIn, _argsToTextArgs, _adaptFile, _storageForFile,
|
|
379
|
+
_tsTypeToString, _qualifiedTypeName, _extractTsTypes,
|
|
380
|
+
};
|