@clear-capabilities/agentic-security-scanner 0.144.0 → 0.147.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 +390 -0
- package/bin/agentic-security.js +3813 -83
- package/dist/1122.index.js +702 -0
- package/dist/{301.index.js → 1301.index.js} +2 -2
- package/dist/1379.index.js +591 -0
- package/dist/{444.index.js → 1444.index.js} +13 -4
- package/dist/{660.index.js → 1660.index.js} +2 -2
- package/dist/{700.index.js → 1700.index.js} +2 -2
- package/dist/{905.index.js → 1905.index.js} +3 -3
- package/dist/{920.index.js → 1920.index.js} +3 -3
- package/dist/{238.index.js → 2238.index.js} +3 -3
- package/dist/2271.index.js +165 -0
- package/dist/{985.index.js → 2376.index.js} +1260 -340
- package/dist/2432.index.js +793 -0
- package/dist/2659.index.js +93 -0
- package/dist/{826.index.js → 2826.index.js} +2 -2
- package/dist/{830.index.js → 2830.index.js} +2 -2
- package/dist/2923.index.js +298 -0
- package/dist/{1.index.js → 3001.index.js} +5 -5
- package/dist/{117.index.js → 3117.index.js} +3 -3
- package/dist/3180.index.js +307 -0
- package/dist/3276.index.js +117 -0
- package/dist/{415.index.js → 3415.index.js} +2 -2
- package/dist/{499.index.js → 3499.index.js} +2 -2
- package/dist/3518.index.js +450 -0
- package/dist/{526.index.js → 3526.index.js} +14 -6
- package/dist/{736.index.js → 3736.index.js} +4 -4
- package/dist/{839.index.js → 3839.index.js} +4 -4
- package/dist/{113.index.js → 4113.index.js} +14 -6
- package/dist/{265.index.js → 4265.index.js} +2 -2
- package/dist/{384.index.js → 4384.index.js} +3 -3
- package/dist/4547.index.js +268 -0
- package/dist/4863.index.js +422 -0
- package/dist/{970.index.js → 4970.index.js} +67 -3
- package/dist/5051.index.js +770 -0
- package/dist/{144.index.js → 5144.index.js} +5 -5
- package/dist/{333.index.js → 5333.index.js} +3 -3
- package/dist/5343.index.js +185 -0
- package/dist/5350.index.js +866 -0
- package/dist/5561.index.js +436 -0
- package/dist/{637.index.js → 5637.index.js} +29 -7
- package/dist/{449.index.js → 5830.index.js} +78 -14
- package/dist/6626.index.js +532 -0
- package/dist/6662.index.js +297 -0
- package/dist/{675.index.js → 6675.index.js} +5 -5
- package/dist/{730.index.js → 6730.index.js} +6 -6
- package/dist/6829.index.js +225 -0
- package/dist/6944.index.js +130 -0
- package/dist/{178.index.js → 7178.index.js} +26 -8
- package/dist/{227.index.js → 7227.index.js} +2 -2
- package/dist/7310.index.js +520 -0
- package/dist/{552.index.js → 7552.index.js} +4 -4
- package/dist/7709.index.js +78 -0
- package/dist/8218.index.js +160 -0
- package/dist/{476.index.js → 8476.index.js} +4 -4
- package/dist/{513.index.js → 8513.index.js} +5 -5
- package/dist/{520.index.js → 8520.index.js} +2 -2
- package/dist/{718.index.js → 8718.index.js} +2 -2
- package/dist/{752.index.js → 8752.index.js} +2 -2
- package/dist/8846.index.js +100 -0
- package/dist/{435.index.js → 9091.index.js} +580 -187
- package/dist/{207.index.js → 9207.index.js} +2 -2
- package/dist/{220.index.js → 9220.index.js} +2 -2
- package/dist/9390.index.js +163 -0
- package/dist/{503.index.js → 9503.index.js} +2 -2
- package/dist/{801.index.js → 9801.index.js} +2 -2
- package/dist/{824.index.js → 9824.index.js} +2 -2
- package/dist/agentic-security.mjs +16 -16
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/dist/compliance-frameworks/hipaa-security-rule.json +3 -2
- package/package.json +23 -10
- package/src/compare.js +6 -1
- package/src/dataflow/CLAUDE.md +2 -2
- package/src/dataflow/catalog.js +42 -0
- package/src/dataflow/orm-write-catalog.js +175 -0
- package/src/engine.js +580 -30
- package/src/fix/apply-fix-service.js +1 -0
- package/src/history-scan.js +22 -5
- package/src/ir/CLAUDE.md +2 -1
- package/src/ir/chrome-probe.mjs +150 -0
- package/src/ir/parser-js.js +94 -7
- package/src/lineage/CLAUDE.md +1203 -0
- package/src/lineage/DESIGN_DESTINATION_RESOLVER.md +156 -0
- package/src/lineage/DESIGN_GRAPH_BUILDER.md +938 -0
- package/src/lineage/DESIGN_HANDLING_ANALYZER.md +355 -0
- package/src/lineage/DESIGN_INTRAPROCEDURAL.md +628 -0
- package/src/lineage/DESIGN_PATH_PROVENANCE.md +3451 -0
- package/src/lineage/DESIGN_QUEUE_DETAIL.md +120 -0
- package/src/lineage/DESIGN_REGISTRIES.md +880 -0
- package/src/lineage/DESIGN_STORE_DETAIL.md +143 -0
- package/src/lineage/DESIGN_TRANSIT_PROTECTION.md +245 -0
- package/src/lineage/classification.js +56 -0
- package/src/lineage/coverage.js +658 -0
- package/src/lineage/cross-repo-link.js +107 -0
- package/src/lineage/dataflow-graph.schema.json +184 -0
- package/src/lineage/decision-story.js +206 -0
- package/src/lineage/drift-policy.js +279 -0
- package/src/lineage/driver.js +135 -0
- package/src/lineage/engine.js +992 -0
- package/src/lineage/export-briefing.js +628 -0
- package/src/lineage/export-csv.js +62 -0
- package/src/lineage/export-json.js +238 -0
- package/src/lineage/export-privacy.js +258 -0
- package/src/lineage/federation-loader.js +111 -0
- package/src/lineage/field-identity.js +78 -0
- package/src/lineage/fixtures/build-flagship-fixture.mjs +272 -0
- package/src/lineage/fixtures/flagship-graph.json +1453 -0
- package/src/lineage/flow-grade.js +221 -0
- package/src/lineage/governance-edit.js +169 -0
- package/src/lineage/graph-builder.js +1114 -0
- package/src/lineage/graph-diff.js +431 -0
- package/src/lineage/graph-snapshot.js +180 -0
- package/src/lineage/handling-analyzer.js +168 -0
- package/src/lineage/ids.js +349 -0
- package/src/lineage/impact-assessment.js +76 -0
- package/src/lineage/impact-engine.js +268 -0
- package/src/lineage/index.js +281 -0
- package/src/lineage/language-coverage-tiers.js +58 -0
- package/src/lineage/obligation-mapping.js +126 -0
- package/src/lineage/obligation-predicates.js +235 -0
- package/src/lineage/observation-adapters.js +282 -0
- package/src/lineage/observation-correlation.js +622 -0
- package/src/lineage/observation-store.js +497 -0
- package/src/lineage/path-query.js +410 -0
- package/src/lineage/path-store.js +400 -0
- package/src/lineage/protection.js +53 -0
- package/src/lineage/recipient-profile.js +192 -0
- package/src/lineage/recipient-registry.js +394 -0
- package/src/lineage/redact-graph.js +224 -0
- package/src/lineage/remediation.js +417 -0
- package/src/lineage/resolve-destination.js +91 -0
- package/src/lineage/runtime-observation.js +464 -0
- package/src/lineage/scenario-diff.js +84 -0
- package/src/lineage/scenario-engine.js +251 -0
- package/src/lineage/scenario.js +101 -0
- package/src/lineage/schema.js +167 -0
- package/src/lineage/sink-registry.js +427 -0
- package/src/lineage/source-registry.js +357 -0
- package/src/lineage/source-seeding.js +212 -0
- package/src/lineage/summaries.js +590 -0
- package/src/lineage/transform-catalog.js +397 -0
- package/src/lineage/transit-protection.js +150 -0
- package/src/lineage/validate.js +285 -0
- package/src/lsp/server.js +49 -2
- package/src/mcp/CLAUDE.md +7 -1
- package/src/mcp/dataflow-tools.js +160 -0
- package/src/mcp/server.js +1 -1
- package/src/mcp/tools.js +22 -1
- package/src/pipeline/assurance-mode.js +64 -1
- package/src/pipeline/finding-schema.js +8 -1
- package/src/pipeline/scan-health.js +19 -1
- package/src/posture/CLAUDE.md +140 -0
- package/src/posture/accuracy-scorecard.js +60 -0
- package/src/posture/artifact-registry.js +76 -0
- package/src/posture/auditor-walkthrough.js +192 -13
- package/src/posture/compliance-frameworks/hipaa-security-rule.json +3 -2
- package/src/posture/compliance-policy.js +12 -2
- package/src/posture/cross-repo-memory.js +7 -2
- package/src/posture/fix-history.js +25 -2
- package/src/posture/fix-verify.js +9 -1
- package/src/posture/fleet.js +0 -0
- package/src/posture/git-history.js +13 -5
- package/src/posture/material-change.js +21 -2
- package/src/posture/mttr.js +75 -12
- package/src/posture/obligation-evidence-pack.js +202 -0
- package/src/posture/pre-incident-archaeology.js +39 -7
- package/src/posture/privacy-framework.js +14 -0
- package/src/posture/provenance/ai-authorship.js +68 -0
- package/src/posture/provenance/branch-entry.js +80 -0
- package/src/posture/provenance/cache.js +143 -0
- package/src/posture/provenance/confidence.js +36 -0
- package/src/posture/provenance/coordinator.js +786 -0
- package/src/posture/provenance/dag-walk.js +249 -0
- package/src/posture/provenance/evidence-attribution.js +59 -0
- package/src/posture/provenance/git-evidence.js +310 -0
- package/src/posture/provenance/lifecycle.js +208 -0
- package/src/posture/provenance/missing-control-resolver.js +137 -0
- package/src/posture/provenance/origin-resolver.js +342 -0
- package/src/posture/provenance/predicate-replay.js +133 -0
- package/src/posture/provenance/providers/config.js +39 -0
- package/src/posture/provenance/providers/github.js +62 -0
- package/src/posture/provenance/providers/gitlab.js +58 -0
- package/src/posture/provenance/repo-lineage.js +74 -0
- package/src/posture/provenance/sca-origin.js +139 -0
- package/src/posture/provenance/schema.js +255 -0
- package/src/posture/provenance/transitive-sca.js +147 -0
- package/src/posture/provenance/validate.js +30 -0
- package/src/posture/provenance-evidence-bundle.js +144 -0
- package/src/posture/remediation-ledger.js +337 -0
- package/src/posture/sbom-diff.js +15 -2
- package/src/posture/secret-history.js +10 -2
- package/src/posture/state-dir.js +38 -14
- package/src/posture/vuln-archaeology.js +8 -2
- package/src/pr-delta.js +25 -4
- package/src/report/index.js +197 -3
- package/src/runScan.js +34 -5
- package/src/sast/rate-limit.js +33 -3
- package/src/server/CLAUDE.md +47 -0
- package/src/server/graph-loader.js +141 -0
- package/src/server/http-server.js +325 -0
- package/src/server/routes.js +129 -0
- package/src/server/security.js +111 -0
- package/src/server/static-assets.js +139 -0
- package/src/util/git-hardening.js +128 -0
- package/dist/11.index.js +0 -353
- package/dist/259.index.js +0 -975
- package/dist/317.index.js +0 -300
- package/dist/609.index.js +0 -741
- package/dist/838.index.js +0 -152
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
//
|
|
2
|
+
// transform-catalog.js — Data Flow Explorer, Sub-project D, increment D4.
|
|
3
|
+
//
|
|
4
|
+
// Recognizes that a CALL performs a security- or privacy-relevant data
|
|
5
|
+
// transformation, and classifies it into DataFlowGraph v1's §10.6
|
|
6
|
+
// vocabulary: `TRANSFORM_KINDS` × `REVERSIBILITY_VALUES` (`schema.js`).
|
|
7
|
+
//
|
|
8
|
+
// ─── What makes this module different from D2/D3 ────────────────────────
|
|
9
|
+
//
|
|
10
|
+
// `source-registry.js` (D2) and `sink-registry.js` (D3) are RECLASSIFICATION
|
|
11
|
+
// layers: they read `dataflow/catalog.js`'s already-proven, corpus-backed
|
|
12
|
+
// entries and map them onto a new vocabulary. This module has no such input.
|
|
13
|
+
// `DESIGN_REGISTRIES.md` §8.2/§8.3 measured why:
|
|
14
|
+
// - the 706 sanitizer entries are keyed on `effect` + a THREAT CLASS
|
|
15
|
+
// ("does this neutralize XSS"), not on "what kind of transformation is
|
|
16
|
+
// this and is it reversible" — reclassifying them would be a category
|
|
17
|
+
// error;
|
|
18
|
+
// - the one genuinely transform-shaped list that exists,
|
|
19
|
+
// `privacy-catalog.js`'s `PRIVACY_TRANSFORM_CALLEES`, is a private
|
|
20
|
+
// Set behind a BOOLEAN predicate (`isPrivacyTransformCallee`) that
|
|
21
|
+
// tells a caller *that* a callee is a transform but never *which kind*.
|
|
22
|
+
// So this catalog is written from scratch. §8.3 left the access problem
|
|
23
|
+
// open between "a lineage-side table of its own" and "export the Set from
|
|
24
|
+
// privacy-catalog.js"; this increment's plan settles it as the former —
|
|
25
|
+
// this module imports NOTHING from `scanner/src/dataflow/` at all (see
|
|
26
|
+
// Isolation below).
|
|
27
|
+
//
|
|
28
|
+
// ─── Isolation ──────────────────────────────────────────────────────────
|
|
29
|
+
//
|
|
30
|
+
// Imports ONLY `TRANSFORM_KINDS` / `REVERSIBILITY_VALUES` from ./schema.js.
|
|
31
|
+
// Never `dataflow/catalog.js`, never `dataflow/privacy-catalog.js`, never
|
|
32
|
+
// `dataflow/engine.js` / `dataflow/summaries.js` / any matcher internals
|
|
33
|
+
// (this PRD's §18.1 isolation principle, the same basis every Sub-project
|
|
34
|
+
// A–D module uses).
|
|
35
|
+
//
|
|
36
|
+
// ─── The scope boundary this module must NOT cross ──────────────────────
|
|
37
|
+
//
|
|
38
|
+
// PRD §10.6 lists nine things a Transformation entity must identify. This
|
|
39
|
+
// module produces FOUR of them, and it is a hard error for it to produce
|
|
40
|
+
// any of the rest:
|
|
41
|
+
//
|
|
42
|
+
// PRODUCED: kind; reversibility; algorithm (when the callee pattern
|
|
43
|
+
// itself names one — never guessed); evidence + confidence.
|
|
44
|
+
//
|
|
45
|
+
// NOT PRODUCED, because they need a CALL SITE and this module only ever
|
|
46
|
+
// sees a callee PATTERN: input/output access paths, function code
|
|
47
|
+
// location, "applies on all feasible paths or only some paths",
|
|
48
|
+
// key-management evidence.
|
|
49
|
+
//
|
|
50
|
+
// NOT PRODUCED, because Milestone 1's own binding scope decision forbids
|
|
51
|
+
// it: **"control credit granted or denied and the reason."** The parent
|
|
52
|
+
// scoping doc's Decision 2 reads: *"Sub-project D (transformation-kind
|
|
53
|
+
// recognition) is in scope for Milestone 1; awarding verdict 'credit'
|
|
54
|
+
// from that recognition is explicitly out of scope and stays
|
|
55
|
+
// `not_assessed` until Milestone 2."* Recognizing that a `mask` happened
|
|
56
|
+
// is this module's job. Deciding whether that `mask` earns "protected"
|
|
57
|
+
// is `protection.js`'s `aggregateVerdicts()` — Milestone 2's FR-401-405
|
|
58
|
+
// analyzers, reading this module's output. If you find yourself adding a
|
|
59
|
+
// field that answers "is this transform good enough", stop.
|
|
60
|
+
//
|
|
61
|
+
// A future Sub-project E graph builder is what calls this once per real
|
|
62
|
+
// call site and combines the answer with location/path data to build an
|
|
63
|
+
// actual `Transformation` entity.
|
|
64
|
+
//
|
|
65
|
+
// ─── §10.6's "never synonyms" rule ──────────────────────────────────────
|
|
66
|
+
//
|
|
67
|
+
// *"Masking, hashing, tokenization, and encryption must never be treated
|
|
68
|
+
// as synonyms."* Each is its own kind here, with its own reversibility,
|
|
69
|
+
// and no entry may collapse two of them:
|
|
70
|
+
// mask → irreversible (information is destroyed);
|
|
71
|
+
// hash → irreversible (one-way by construction);
|
|
72
|
+
// tokenize → REVERSIBLE (a token is designed to be detokenized by the
|
|
73
|
+
// vault that issued it — this is precisely the property that
|
|
74
|
+
// makes it not-a-mask);
|
|
75
|
+
// encrypt → REVERSIBLE (designed to be decrypted).
|
|
76
|
+
//
|
|
77
|
+
// ─── Curated, not exhaustive (and the confidence rule) ──────────────────
|
|
78
|
+
//
|
|
79
|
+
// This is new detection capability, so it favors PRECISION over recall. A
|
|
80
|
+
// callee this catalog does not recognize returns `null` — never a guess.
|
|
81
|
+
//
|
|
82
|
+
// confidence: 'high' — the pattern identifies both the callee AND its
|
|
83
|
+
// transformation semantics unambiguously: a
|
|
84
|
+
// qualified library/module API (`crypto.createHash`,
|
|
85
|
+
// `bcrypt.hash`, `hashlib.sha256`) or a platform
|
|
86
|
+
// global whose name is effectively reserved
|
|
87
|
+
// (`encodeURIComponent`, `btoa`).
|
|
88
|
+
// confidence: 'medium' — EITHER the name is unqualified and an
|
|
89
|
+
// application could define it with unrelated
|
|
90
|
+
// semantics (`maskCard`, `tokenize`, `normalize`),
|
|
91
|
+
// OR the API is genuinely general-purpose and its
|
|
92
|
+
// per-call-site semantics vary (`_.truncate` is
|
|
93
|
+
// string shortening, not necessarily a privacy
|
|
94
|
+
// control).
|
|
95
|
+
//
|
|
96
|
+
// There is no 'low' tier: a pattern that would only justify 'low' does not
|
|
97
|
+
// belong in a curated catalog at all — it belongs in the disclosed-gap
|
|
98
|
+
// list below.
|
|
99
|
+
//
|
|
100
|
+
// ─── Deliberate gaps (disclosed, per this PRD's own convention) ─────────
|
|
101
|
+
//
|
|
102
|
+
// 1. `custom` and `unknown` are NEVER emitted. They are a consumer's
|
|
103
|
+
// fallback vocabulary for a transform-shaped call this catalog does not
|
|
104
|
+
// recognize; the honest answer from a RECOGNIZER is `null`. Whoever
|
|
105
|
+
// must still materialize a Transformation entity for an unrecognized
|
|
106
|
+
// call is the one who chooses between `custom` and `unknown`.
|
|
107
|
+
// 2. `anonymize*` / `pseudonymize*` are deliberately EXCLUDED even though
|
|
108
|
+
// both are real, common names (and both are in `privacy-catalog.js`'s
|
|
109
|
+
// own private Set). Neither maps onto a single `TRANSFORM_KINDS` value:
|
|
110
|
+
// anonymization is variously masking, redaction or aggregation, and
|
|
111
|
+
// pseudonymization is variously tokenization or keyed hashing. Forcing
|
|
112
|
+
// either into one kind is exactly the synonym-collapse §10.6 forbids.
|
|
113
|
+
// 3. `aggregate` and `tokenize` are the THINNEST kinds here — one
|
|
114
|
+
// naming-convention entry each. Aggregation is normally a data-shape
|
|
115
|
+
// operation (`.reduce()`, `.groupBy()`) with no security-relevant
|
|
116
|
+
// callee to key on, and payment/PII tokenization vaults are
|
|
117
|
+
// overwhelmingly vendor-proprietary, so no non-invented library entry
|
|
118
|
+
// was available. `tokenize*` additionally collides with NLP/lexer
|
|
119
|
+
// tokenization (`tokenize(sourceText)`), which is a real transformation
|
|
120
|
+
// but not this one — hence 'medium' and an evidence string that says so.
|
|
121
|
+
// 4. Serialization/deserialization (`JSON.stringify`, `JSON.parse`) is
|
|
122
|
+
// NOT cataloged as `encode`/`decode`. `MAPPING_TYPES` in `schema.js`
|
|
123
|
+
// already has `serialization`/`deserialization` as their own values —
|
|
124
|
+
// they are an edge's mapping type, not a transformation kind.
|
|
125
|
+
// 5. Argument-borne facts are never read. `crypto.createHash('sha256')`
|
|
126
|
+
// yields `algorithm: null` here, because this module classifies a
|
|
127
|
+
// CALLEE pattern and never sees a call site's arguments. A future
|
|
128
|
+
// graph builder with the call site in hand can refine it.
|
|
129
|
+
// 6. `encodeURIComponent` is ALSO a sanitizer in `dataflow/catalog.js`.
|
|
130
|
+
// That is not a conflict: "neutralizes XSS" and "is an `encode`
|
|
131
|
+
// transformation" are two different axes for two different consumers.
|
|
132
|
+
// This module never imports or consults that classification.
|
|
133
|
+
// 7. Factory attribution (task review M1a): `crypto.createHash`/
|
|
134
|
+
// `createCipheriv`/`createHmac`, and Java's `MessageDigest.getInstance`,
|
|
135
|
+
// classify the FACTORY call, not the later call where the data actually
|
|
136
|
+
// flows (`h.update(pii); h.digest()`). Sub-project E — the first real
|
|
137
|
+
// consumer of this module, which attaches access paths and code
|
|
138
|
+
// location to build a real Transformation entity — must not assume the
|
|
139
|
+
// factory call site is where the input/output paths live; it is only
|
|
140
|
+
// the call site that names WHICH transformation is about to happen.
|
|
141
|
+
// 8. Language scope (task review M1b): the `py`/`java` entries (9 of 42)
|
|
142
|
+
// are ahead of this engine's current JS/TS-only scope (Sub-project D's
|
|
143
|
+
// own scoping doc explicitly excludes any language beyond JS/TS) and
|
|
144
|
+
// cannot fire against anything this codebase parses today. Kept,
|
|
145
|
+
// disclosed rather than removed, since each carries a distinctive
|
|
146
|
+
// qualified receiver (`hashlib.*`, `base64.*`,
|
|
147
|
+
// `MessageDigest.getInstance`) with no plausible JS collision risk.
|
|
148
|
+
// 9. The `language` field (task review M1c) is DOCUMENTARY ONLY —
|
|
149
|
+
// `_entryMatches` never reads it. Matching is language-agnostic by
|
|
150
|
+
// construction; a caller must not assume passing a `language` hint
|
|
151
|
+
// narrows which entries can match, because none does.
|
|
152
|
+
//
|
|
153
|
+
// ─── Entry shape ────────────────────────────────────────────────────────
|
|
154
|
+
//
|
|
155
|
+
// Structurally parallel to `dataflow/catalog.js` (one entry per logical
|
|
156
|
+
// row, with `id` / `language` / `match`) for a future reader's
|
|
157
|
+
// familiarity — but it is NOT that catalog's data and never reads it.
|
|
158
|
+
//
|
|
159
|
+
// { id, language, // documentary only — see gap 9 above, never read by matching
|
|
160
|
+
// kind, reversibility, algorithm, confidence,
|
|
161
|
+
// match: { type: 'call', callee } // bare/global call
|
|
162
|
+
// | { type: 'member-call', object, method } // `object.method()`
|
|
163
|
+
// | { type: 'name-pattern', pattern, notObjects? },
|
|
164
|
+
// evidence, // '{name}' is replaced with the matched callee text
|
|
165
|
+
// examples } // every entry is proven matchable by a real test
|
|
166
|
+
//
|
|
167
|
+
|
|
168
|
+
import { TRANSFORM_KINDS, REVERSIBILITY_VALUES } from './schema.js';
|
|
169
|
+
|
|
170
|
+
// Receivers that mean "this is still the global builtin" for a `call`
|
|
171
|
+
// entry — `window.btoa(x)` is `btoa(x)`.
|
|
172
|
+
const GLOBAL_RECEIVERS = Object.freeze(['window', 'globalThis', 'global', 'self']);
|
|
173
|
+
|
|
174
|
+
// A naming-convention pattern matches `verb`, `verbSomething`, `verb_something`
|
|
175
|
+
// and `verb2` — but never `verbed`/`verbing`/`verbal`, and never a name that
|
|
176
|
+
// merely CONTAINS the verb (`applyMask` is a bitmask helper, not a mask
|
|
177
|
+
// transform — `privacy-catalog.js`'s own comment records that same trap).
|
|
178
|
+
const namingConvention = (verb) => new RegExp(`^${verb}(?=[A-Z_0-9]|$)`);
|
|
179
|
+
|
|
180
|
+
export const TRANSFORM_CATALOG = Object.freeze([
|
|
181
|
+
// ─── hash — irreversible by construction ───────────────────────────────
|
|
182
|
+
{ id: 'js-node-create-hash', language: 'js', kind: 'hash', reversibility: 'irreversible', algorithm: null, confidence: 'high', match: { type: 'member-call', object: 'crypto', method: 'createHash' }, evidence: "call to '{name}' — Node's built-in cryptographic hash factory (the digest algorithm is an argument, not read here)", examples: ['crypto.createHash'] },
|
|
183
|
+
{ id: 'js-node-create-hmac', language: 'js', kind: 'hash', reversibility: 'irreversible', algorithm: null, confidence: 'high', match: { type: 'member-call', object: 'crypto', method: 'createHmac' }, evidence: "call to '{name}' — Node's built-in keyed-MAC factory; a keyed hash, still one-way (the digest algorithm is an argument, not read here)", examples: ['crypto.createHmac'] },
|
|
184
|
+
{ id: 'js-node-pbkdf2', language: 'js', kind: 'hash', reversibility: 'irreversible', algorithm: 'pbkdf2', confidence: 'high', match: { type: 'member-call', object: 'crypto', method: 'pbkdf2' }, evidence: "call to '{name}' — Node's built-in PBKDF2 key-derivation function", examples: ['crypto.pbkdf2'] },
|
|
185
|
+
{ id: 'js-node-pbkdf2-sync', language: 'js', kind: 'hash', reversibility: 'irreversible', algorithm: 'pbkdf2', confidence: 'high', match: { type: 'member-call', object: 'crypto', method: 'pbkdf2Sync' }, evidence: "call to '{name}' — Node's built-in PBKDF2 key-derivation function (sync form)", examples: ['crypto.pbkdf2Sync'] },
|
|
186
|
+
{ id: 'js-node-scrypt', language: 'js', kind: 'hash', reversibility: 'irreversible', algorithm: 'scrypt', confidence: 'high', match: { type: 'member-call', object: 'crypto', method: 'scrypt' }, evidence: "call to '{name}' — Node's built-in scrypt key-derivation function", examples: ['crypto.scrypt'] },
|
|
187
|
+
{ id: 'js-node-scrypt-sync', language: 'js', kind: 'hash', reversibility: 'irreversible', algorithm: 'scrypt', confidence: 'high', match: { type: 'member-call', object: 'crypto', method: 'scryptSync' }, evidence: "call to '{name}' — Node's built-in scrypt key-derivation function (sync form)", examples: ['crypto.scryptSync'] },
|
|
188
|
+
{ id: 'js-webcrypto-digest', language: 'js', kind: 'hash', reversibility: 'irreversible', algorithm: null, confidence: 'high', match: { type: 'member-call', object: 'subtle', method: 'digest' }, evidence: "call to '{name}' — the Web Crypto API's SubtleCrypto.digest (the digest algorithm is an argument, not read here)", examples: ['crypto.subtle.digest'] },
|
|
189
|
+
{ id: 'js-bcrypt-hash', language: 'js', kind: 'hash', reversibility: 'irreversible', algorithm: 'bcrypt', confidence: 'high', match: { type: 'member-call', object: 'bcrypt', method: 'hash' }, evidence: "call to '{name}' — the bcrypt password-hashing library", examples: ['bcrypt.hash'] },
|
|
190
|
+
{ id: 'js-bcrypt-hash-sync', language: 'js', kind: 'hash', reversibility: 'irreversible', algorithm: 'bcrypt', confidence: 'high', match: { type: 'member-call', object: 'bcrypt', method: 'hashSync' }, evidence: "call to '{name}' — the bcrypt password-hashing library (sync form)", examples: ['bcrypt.hashSync'] },
|
|
191
|
+
{ id: 'js-argon2-hash', language: 'js', kind: 'hash', reversibility: 'irreversible', algorithm: 'argon2', confidence: 'high', match: { type: 'member-call', object: 'argon2', method: 'hash' }, evidence: "call to '{name}' — the argon2 password-hashing library", examples: ['argon2.hash'] },
|
|
192
|
+
{ id: 'py-hashlib-sha256', language: 'py', kind: 'hash', reversibility: 'irreversible', algorithm: 'sha256', confidence: 'high', match: { type: 'member-call', object: 'hashlib', method: 'sha256' }, evidence: "call to '{name}' — Python's stdlib hashlib; the callee name itself states the digest algorithm", examples: ['hashlib.sha256'] },
|
|
193
|
+
{ id: 'py-hashlib-sha512', language: 'py', kind: 'hash', reversibility: 'irreversible', algorithm: 'sha512', confidence: 'high', match: { type: 'member-call', object: 'hashlib', method: 'sha512' }, evidence: "call to '{name}' — Python's stdlib hashlib; the callee name itself states the digest algorithm", examples: ['hashlib.sha512'] },
|
|
194
|
+
{ id: 'py-hashlib-sha1', language: 'py', kind: 'hash', reversibility: 'irreversible', algorithm: 'sha1', confidence: 'high', match: { type: 'member-call', object: 'hashlib', method: 'sha1' }, evidence: "call to '{name}' — Python's stdlib hashlib; the callee name itself states the digest algorithm (recognized, not endorsed: SHA-1 is broken for collision resistance)", examples: ['hashlib.sha1'] },
|
|
195
|
+
{ id: 'py-hashlib-md5', language: 'py', kind: 'hash', reversibility: 'irreversible', algorithm: 'md5', confidence: 'high', match: { type: 'member-call', object: 'hashlib', method: 'md5' }, evidence: "call to '{name}' — Python's stdlib hashlib; the callee name itself states the digest algorithm (recognized, not endorsed: MD5 is broken)", examples: ['hashlib.md5'] },
|
|
196
|
+
{ id: 'java-message-digest', language: 'java', kind: 'hash', reversibility: 'irreversible', algorithm: null, confidence: 'high', match: { type: 'member-call', object: 'MessageDigest', method: 'getInstance' }, evidence: "call to '{name}' — the JCA MessageDigest factory (the digest algorithm is an argument, not read here)", examples: ['MessageDigest.getInstance'] },
|
|
197
|
+
|
|
198
|
+
// ─── encrypt — reversible by design ────────────────────────────────────
|
|
199
|
+
{ id: 'js-node-cipheriv', language: 'js', kind: 'encrypt', reversibility: 'reversible', algorithm: null, confidence: 'high', match: { type: 'member-call', object: 'crypto', method: 'createCipheriv' }, evidence: "call to '{name}' — Node's built-in symmetric cipher factory (the cipher suite is an argument, not read here)", examples: ['crypto.createCipheriv'] },
|
|
200
|
+
{ id: 'js-node-cipher-legacy', language: 'js', kind: 'encrypt', reversibility: 'reversible', algorithm: null, confidence: 'high', match: { type: 'member-call', object: 'crypto', method: 'createCipher' }, evidence: "call to '{name}' — Node's deprecated symmetric cipher factory (removed in Node 22; still present in legacy code)", examples: ['crypto.createCipher'] },
|
|
201
|
+
{ id: 'js-node-public-encrypt', language: 'js', kind: 'encrypt', reversibility: 'reversible', algorithm: null, confidence: 'high', match: { type: 'member-call', object: 'crypto', method: 'publicEncrypt' }, evidence: "call to '{name}' — Node's built-in public-key encryption (the key and padding are arguments, not read here)", examples: ['crypto.publicEncrypt'] },
|
|
202
|
+
{ id: 'js-webcrypto-encrypt', language: 'js', kind: 'encrypt', reversibility: 'reversible', algorithm: null, confidence: 'high', match: { type: 'member-call', object: 'subtle', method: 'encrypt' }, evidence: "call to '{name}' — the Web Crypto API's SubtleCrypto.encrypt (the cipher suite is an argument, not read here)", examples: ['crypto.subtle.encrypt'] },
|
|
203
|
+
{ id: 'app-encrypt-naming', language: '*', kind: 'encrypt', reversibility: 'reversible', algorithm: null, confidence: 'medium', match: { type: 'name-pattern', pattern: namingConvention('encrypt') }, evidence: "callee name '{name}' matches the `encrypt*` naming convention (medium: an application-defined name, not a library API — the cipher and key management are unknown)", examples: ['encrypt', 'encryptCardNumber', 'vault.encryptField'] },
|
|
204
|
+
|
|
205
|
+
// ─── decrypt — the direct counterparts ─────────────────────────────────
|
|
206
|
+
{ id: 'js-node-decipheriv', language: 'js', kind: 'decrypt', reversibility: 'reversible', algorithm: null, confidence: 'high', match: { type: 'member-call', object: 'crypto', method: 'createDecipheriv' }, evidence: "call to '{name}' — Node's built-in symmetric decipher factory (the cipher suite is an argument, not read here)", examples: ['crypto.createDecipheriv'] },
|
|
207
|
+
{ id: 'js-node-decipher-legacy',language: 'js', kind: 'decrypt', reversibility: 'reversible', algorithm: null, confidence: 'high', match: { type: 'member-call', object: 'crypto', method: 'createDecipher' }, evidence: "call to '{name}' — Node's deprecated symmetric decipher factory (removed in Node 22; still present in legacy code)", examples: ['crypto.createDecipher'] },
|
|
208
|
+
{ id: 'js-node-private-decrypt',language: 'js', kind: 'decrypt', reversibility: 'reversible', algorithm: null, confidence: 'high', match: { type: 'member-call', object: 'crypto', method: 'privateDecrypt' }, evidence: "call to '{name}' — Node's built-in private-key decryption (the key and padding are arguments, not read here)", examples: ['crypto.privateDecrypt'] },
|
|
209
|
+
{ id: 'js-webcrypto-decrypt', language: 'js', kind: 'decrypt', reversibility: 'reversible', algorithm: null, confidence: 'high', match: { type: 'member-call', object: 'subtle', method: 'decrypt' }, evidence: "call to '{name}' — the Web Crypto API's SubtleCrypto.decrypt (the cipher suite is an argument, not read here)", examples: ['crypto.subtle.decrypt'] },
|
|
210
|
+
{ id: 'app-decrypt-naming', language: '*', kind: 'decrypt', reversibility: 'reversible', algorithm: null, confidence: 'medium', match: { type: 'name-pattern', pattern: namingConvention('decrypt') }, evidence: "callee name '{name}' matches the `decrypt*` naming convention (medium: an application-defined name, not a library API)", examples: ['decrypt', 'decryptCardNumber', 'vault.decryptField'] },
|
|
211
|
+
|
|
212
|
+
// ─── encode — reversible ───────────────────────────────────────────────
|
|
213
|
+
{ id: 'js-encode-uri-component',language: 'js', kind: 'encode', reversibility: 'reversible', algorithm: null, confidence: 'high', match: { type: 'call', callee: 'encodeURIComponent' }, evidence: "call to '{name}' — the ECMAScript global URI-component encoder (classified here on the TRANSFORMATION axis; its separate role as an XSS sanitizer is a different axis this module never consults)", examples: ['encodeURIComponent', 'window.encodeURIComponent'] },
|
|
214
|
+
{ id: 'js-encode-uri', language: 'js', kind: 'encode', reversibility: 'reversible', algorithm: null, confidence: 'high', match: { type: 'call', callee: 'encodeURI' }, evidence: "call to '{name}' — the ECMAScript global URI encoder", examples: ['encodeURI'] },
|
|
215
|
+
{ id: 'js-btoa', language: 'js', kind: 'encode', reversibility: 'reversible', algorithm: 'base64', confidence: 'high', match: { type: 'call', callee: 'btoa' }, evidence: "call to '{name}' — the platform global base64 encoder (base64 by specification, so the algorithm is statically knowable from the callee alone)", examples: ['btoa', 'window.btoa'] },
|
|
216
|
+
{ id: 'py-b64encode', language: 'py', kind: 'encode', reversibility: 'reversible', algorithm: 'base64', confidence: 'high', match: { type: 'member-call', object: 'base64', method: 'b64encode' }, evidence: "call to '{name}' — Python's stdlib base64 encoder", examples: ['base64.b64encode'] },
|
|
217
|
+
{ id: 'py-b64encode-urlsafe', language: 'py', kind: 'encode', reversibility: 'reversible', algorithm: 'base64', confidence: 'high', match: { type: 'member-call', object: 'base64', method: 'urlsafe_b64encode' }, evidence: "call to '{name}' — Python's stdlib URL-safe base64 encoder", examples: ['base64.urlsafe_b64encode'] },
|
|
218
|
+
|
|
219
|
+
// ─── decode — the direct counterparts ──────────────────────────────────
|
|
220
|
+
{ id: 'js-decode-uri-component',language: 'js', kind: 'decode', reversibility: 'reversible', algorithm: null, confidence: 'high', match: { type: 'call', callee: 'decodeURIComponent' }, evidence: "call to '{name}' — the ECMAScript global URI-component decoder", examples: ['decodeURIComponent', 'window.decodeURIComponent'] },
|
|
221
|
+
{ id: 'js-decode-uri', language: 'js', kind: 'decode', reversibility: 'reversible', algorithm: null, confidence: 'high', match: { type: 'call', callee: 'decodeURI' }, evidence: "call to '{name}' — the ECMAScript global URI decoder", examples: ['decodeURI'] },
|
|
222
|
+
{ id: 'js-atob', language: 'js', kind: 'decode', reversibility: 'reversible', algorithm: 'base64', confidence: 'high', match: { type: 'call', callee: 'atob' }, evidence: "call to '{name}' — the platform global base64 decoder (base64 by specification, so the algorithm is statically knowable from the callee alone)", examples: ['atob', 'window.atob'] },
|
|
223
|
+
{ id: 'py-b64decode', language: 'py', kind: 'decode', reversibility: 'reversible', algorithm: 'base64', confidence: 'high', match: { type: 'member-call', object: 'base64', method: 'b64decode' }, evidence: "call to '{name}' — Python's stdlib base64 decoder", examples: ['base64.b64decode'] },
|
|
224
|
+
{ id: 'py-b64decode-urlsafe', language: 'py', kind: 'decode', reversibility: 'reversible', algorithm: 'base64', confidence: 'high', match: { type: 'member-call', object: 'base64', method: 'urlsafe_b64decode' }, evidence: "call to '{name}' — Python's stdlib URL-safe base64 decoder", examples: ['base64.urlsafe_b64decode'] },
|
|
225
|
+
|
|
226
|
+
// ─── mask — irreversible; naming convention only, by measurement ───────
|
|
227
|
+
// Masking has no canonical library the way hashing does: it is written
|
|
228
|
+
// per-application (`maskCard`, `maskSSN`, `maskEmail`). The convention IS
|
|
229
|
+
// the only reliable signal, so this kind tops out at 'medium'.
|
|
230
|
+
{ id: 'app-mask-naming', language: '*', kind: 'mask', reversibility: 'irreversible', algorithm: null, confidence: 'medium', match: { type: 'name-pattern', pattern: namingConvention('mask') }, evidence: "callee name '{name}' matches the app-level `mask*` naming convention (medium: no canonical masking library exists to key on, and a same-named application function could be e.g. a bitmask helper)", examples: ['maskCard', 'maskSSN', 'pii.maskEmail'] },
|
|
231
|
+
|
|
232
|
+
// ─── redact — irreversible; naming convention only ─────────────────────
|
|
233
|
+
{ id: 'app-redact-naming', language: '*', kind: 'redact', reversibility: 'irreversible', algorithm: null, confidence: 'medium', match: { type: 'name-pattern', pattern: namingConvention('redact') }, evidence: "callee name '{name}' matches the app-level `redact*` naming convention (medium: the well-known log redactors are configured declaratively, not called by a stable name, so there is no library API to key on)", examples: ['redact', 'redactSecrets', 'log.redactFields'] },
|
|
234
|
+
|
|
235
|
+
// ─── tokenize — REVERSIBLE, and deliberately not a mask ────────────────
|
|
236
|
+
{ id: 'app-tokenize-naming', language: '*', kind: 'tokenize', reversibility: 'reversible', algorithm: null, confidence: 'medium', match: { type: 'name-pattern', pattern: namingConvention('tokenize') }, evidence: "callee name '{name}' matches the `tokenize*` naming convention (medium: tokenization vaults are overwhelmingly vendor-proprietary, so no library API was available to key on; and this name also denotes NLP/lexer tokenization, a different transformation entirely)", examples: ['tokenize', 'tokenizeCard', 'vault.tokenizePAN'] },
|
|
237
|
+
|
|
238
|
+
// ─── aggregate — irreversible (per-record detail is discarded) ─────────
|
|
239
|
+
{ id: 'app-aggregate-naming', language: '*', kind: 'aggregate', reversibility: 'irreversible', algorithm: null, confidence: 'medium', match: { type: 'name-pattern', pattern: namingConvention('aggregate') }, evidence: "callee name '{name}' matches the `aggregate*` naming convention (medium: this is the thinnest kind in the catalog — aggregation is normally an unnamed data-shape operation like `.reduce()`/`.groupBy()` with no callee to key on)", examples: ['aggregate', 'aggregateByRegion', 'collection.aggregate'] },
|
|
240
|
+
|
|
241
|
+
// ─── truncate — irreversible ───────────────────────────────────────────
|
|
242
|
+
{ id: 'js-lodash-truncate', language: 'js', kind: 'truncate', reversibility: 'irreversible', algorithm: null, confidence: 'medium', match: { type: 'member-call', object: '_', method: 'truncate' }, evidence: "call to '{name}' — lodash's string truncation (medium not because the callee is ambiguous but because the API is general-purpose: it is not necessarily applied as a privacy control)", examples: ['_.truncate'] },
|
|
243
|
+
// `notObjects` keeps a real, unrelated Node API out: `fs.truncate` /
|
|
244
|
+
// `fsPromises.truncate` shorten a FILE, they do not transform a value.
|
|
245
|
+
{ id: 'app-truncate-naming', language: '*', kind: 'truncate', reversibility: 'irreversible', algorithm: null, confidence: 'medium', match: { type: 'name-pattern', pattern: namingConvention('truncate'), notObjects: ['fs', 'fsPromises', 'fsp'] }, evidence: "callee name '{name}' matches the `truncate*` naming convention (medium: general-purpose shortening, not necessarily a privacy control; `fs.truncate`, which shortens a file rather than a value, is excluded)", examples: ['truncate', 'truncateEmail', 'text.truncateTo'] },
|
|
246
|
+
|
|
247
|
+
// ─── normalize — reversibility genuinely UNKNOWN ───────────────────────
|
|
248
|
+
// Unicode normalization can be lossless (NFC/NFD round-trip) or lossy
|
|
249
|
+
// (NFKC/NFKD), path normalization discards `..` segments, and email
|
|
250
|
+
// normalization lowercases and strips tags. The callee alone cannot say
|
|
251
|
+
// which — so `unknown` is the honest value, not an overclaim in either
|
|
252
|
+
// direction.
|
|
253
|
+
{ id: 'app-normalize-naming', language: '*', kind: 'normalize', reversibility: 'unknown', algorithm: null, confidence: 'medium', match: { type: 'name-pattern', pattern: namingConvention('normalize') }, evidence: "callee name '{name}' matches the `normalize*` convention, which the ecosystem uses consistently for normalization (String.prototype.normalize, path.normalize, validator.normalizeEmail) — medium because this name-level match cannot tell the ECMAScript builtin from a same-named application function, and the normalization FORM (lossy or lossless) is never knowable from the callee", examples: ['normalize', 'normalizeEmail', 'path.normalize', 'validator.normalizeEmail'] },
|
|
254
|
+
]);
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Split a callee descriptor into the pieces the matchers need.
|
|
258
|
+
* Returns `null` for anything malformed — a caller that could not fully
|
|
259
|
+
* resolve a callee may legitimately hand over a partial descriptor, and
|
|
260
|
+
* that is "no match", never a crash.
|
|
261
|
+
*/
|
|
262
|
+
function _normalizeDescriptor(descriptor) {
|
|
263
|
+
if (!descriptor || typeof descriptor !== 'object' || Array.isArray(descriptor)) return null;
|
|
264
|
+
|
|
265
|
+
if (descriptor.type === 'call') {
|
|
266
|
+
const { callee } = descriptor;
|
|
267
|
+
if (typeof callee !== 'string' || callee.trim() === '') return null;
|
|
268
|
+
const segs = callee.split('.').filter(Boolean);
|
|
269
|
+
if (segs.length === 0) return null;
|
|
270
|
+
return {
|
|
271
|
+
name: segs[segs.length - 1],
|
|
272
|
+
object: segs.length >= 2 ? segs[segs.length - 2] : null,
|
|
273
|
+
display: callee,
|
|
274
|
+
bare: segs.length === 1,
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (descriptor.type === 'member-call') {
|
|
279
|
+
const { object, method } = descriptor;
|
|
280
|
+
if (typeof object !== 'string' || object.trim() === '') return null;
|
|
281
|
+
if (typeof method !== 'string' || method.trim() === '') return null;
|
|
282
|
+
const objSegs = object.split('.').filter(Boolean);
|
|
283
|
+
if (objSegs.length === 0) return null;
|
|
284
|
+
return {
|
|
285
|
+
name: method,
|
|
286
|
+
object: objSegs[objSegs.length - 1],
|
|
287
|
+
display: `${object}.${method}`,
|
|
288
|
+
bare: false,
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
return null;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function _entryMatches(entry, c) {
|
|
296
|
+
const m = entry.match;
|
|
297
|
+
if (m.type === 'call') {
|
|
298
|
+
if (c.name !== m.callee) return false;
|
|
299
|
+
return c.bare || GLOBAL_RECEIVERS.includes(c.object);
|
|
300
|
+
}
|
|
301
|
+
if (m.type === 'member-call') {
|
|
302
|
+
return c.object === m.object && c.name === m.method;
|
|
303
|
+
}
|
|
304
|
+
if (m.type === 'name-pattern') {
|
|
305
|
+
if (!m.pattern.test(c.name)) return false;
|
|
306
|
+
if (m.notObjects && c.object && m.notObjects.includes(c.object)) return false;
|
|
307
|
+
return true;
|
|
308
|
+
}
|
|
309
|
+
return false;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Classify a callee pattern as a §10.6 data transformation.
|
|
314
|
+
*
|
|
315
|
+
* @param {{type: 'call', callee: string}
|
|
316
|
+
* |{type: 'member-call', object: string, method: string}} calleeDescriptor
|
|
317
|
+
* A bare call (`maskCard(x)` → `{type:'call', callee:'maskCard'}`) or a
|
|
318
|
+
* method call (`crypto.createHash('sha256')` →
|
|
319
|
+
* `{type:'member-call', object:'crypto', method:'createHash'}`). A dotted
|
|
320
|
+
* `callee` string (`'crypto.createHash'`) is accepted too and resolves
|
|
321
|
+
* identically. Anything else — including `undefined`, a partial shape or
|
|
322
|
+
* an unrecognized `type` — returns `null` rather than throwing.
|
|
323
|
+
*
|
|
324
|
+
* @returns {{kind: string, reversibility: string, algorithm: string|null,
|
|
325
|
+
* confidence: 'high'|'medium', evidence: string}|null}
|
|
326
|
+
* `null` when this catalog recognizes no transformation. `kind` is always
|
|
327
|
+
* a `TRANSFORM_KINDS` value and `reversibility` always a
|
|
328
|
+
* `REVERSIBILITY_VALUES` value. There is deliberately NO control-credit
|
|
329
|
+
* field — see this file's header.
|
|
330
|
+
*/
|
|
331
|
+
export function recognizeTransformation(calleeDescriptor) {
|
|
332
|
+
const c = _normalizeDescriptor(calleeDescriptor);
|
|
333
|
+
if (!c) return null;
|
|
334
|
+
|
|
335
|
+
// First match wins. Exact `call`/`member-call` entries are listed ahead
|
|
336
|
+
// of the naming-convention entries for each kind, so a library API can
|
|
337
|
+
// never be reported as a weaker naming-convention match.
|
|
338
|
+
for (const entry of TRANSFORM_CATALOG) {
|
|
339
|
+
if (!_entryMatches(entry, c)) continue;
|
|
340
|
+
return {
|
|
341
|
+
kind: entry.kind,
|
|
342
|
+
reversibility: entry.reversibility,
|
|
343
|
+
algorithm: entry.algorithm,
|
|
344
|
+
confidence: entry.confidence,
|
|
345
|
+
evidence: entry.evidence.replace('{name}', c.display),
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
return null;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* The exact key set of a non-null `recognizeTransformation` result.
|
|
353
|
+
* Exported so a consumer — and this module's own tests — can assert the
|
|
354
|
+
* shape without hand-copying it, and so Decision 2's boundary (no
|
|
355
|
+
* control-credit field, ever) is enforceable structurally rather than by
|
|
356
|
+
* reading prose.
|
|
357
|
+
*/
|
|
358
|
+
export const TRANSFORM_DECISION_KEYS = Object.freeze([
|
|
359
|
+
'kind', 'reversibility', 'algorithm', 'confidence', 'evidence',
|
|
360
|
+
]);
|
|
361
|
+
|
|
362
|
+
/** Confidence tiers this catalog can emit. There is deliberately no 'low'. */
|
|
363
|
+
export const TRANSFORM_CONFIDENCE_VALUES = Object.freeze(['high', 'medium']);
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* The `TRANSFORM_KINDS` values this catalog deliberately never emits — a
|
|
367
|
+
* recognizer's honest answer for an unrecognized callee is `null`, and
|
|
368
|
+
* choosing between `custom` and `unknown` belongs to whoever must still
|
|
369
|
+
* materialize a Transformation entity. Kept as data (not prose alone) so a
|
|
370
|
+
* test can pin it, per this package's disclose-the-gap convention.
|
|
371
|
+
*/
|
|
372
|
+
export const NEVER_EMITTED_KINDS = Object.freeze(['custom', 'unknown']);
|
|
373
|
+
|
|
374
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
375
|
+
// Load-time integrity check. This is the ONLY use of the two schema.js
|
|
376
|
+
// imports, and it is deliberate: a catalog that hardcodes enum strings
|
|
377
|
+
// without ever referencing the source of truth is exactly the silent-drift
|
|
378
|
+
// hazard this package's own conventions warn about. A failure here is a
|
|
379
|
+
// programming error in THIS file (never bad caller input), so failing at
|
|
380
|
+
// import is correct — a catalog entry carrying a value outside
|
|
381
|
+
// `TRANSFORM_KINDS`/`REVERSIBILITY_VALUES` would otherwise reach a
|
|
382
|
+
// DataFlowGraph v1 Transformation entity and validate nowhere.
|
|
383
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
384
|
+
for (const entry of TRANSFORM_CATALOG) {
|
|
385
|
+
Object.freeze(entry);
|
|
386
|
+
Object.freeze(entry.match);
|
|
387
|
+
Object.freeze(entry.examples);
|
|
388
|
+
if (!TRANSFORM_KINDS.includes(entry.kind)) {
|
|
389
|
+
throw new Error(`transform-catalog.js: entry '${entry.id}' has kind '${entry.kind}', which is not in TRANSFORM_KINDS`);
|
|
390
|
+
}
|
|
391
|
+
if (NEVER_EMITTED_KINDS.includes(entry.kind)) {
|
|
392
|
+
throw new Error(`transform-catalog.js: entry '${entry.id}' emits '${entry.kind}', which this catalog must never emit (return null instead)`);
|
|
393
|
+
}
|
|
394
|
+
if (!REVERSIBILITY_VALUES.includes(entry.reversibility)) {
|
|
395
|
+
throw new Error(`transform-catalog.js: entry '${entry.id}' has reversibility '${entry.reversibility}', which is not in REVERSIBILITY_VALUES`);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
//
|
|
2
|
+
// transit-protection.js — Milestone 2, Sub-project B ("transit protection
|
|
3
|
+
// analyzer", FR-401), increments 1 (plumbing skeleton) and 2 (verdict
|
|
4
|
+
// computation).
|
|
5
|
+
//
|
|
6
|
+
// Increment 1 shipped the ONLY thing it needed: running the already-shipped
|
|
7
|
+
// `crypto-protocol.js` whole-file/whole-repo TLS/cipher detector over every
|
|
8
|
+
// file's raw source text and returning a real, inspectable
|
|
9
|
+
// `Map<file, findings[]>`.
|
|
10
|
+
//
|
|
11
|
+
// Increment 2 (this addition) adds the real per-site verdict function,
|
|
12
|
+
// `resolveTransitProtectionForSite(site, transitEvidenceByFile)` — the
|
|
13
|
+
// `opts.resolveTransitProtection` hook `graph-builder.js` now composes into
|
|
14
|
+
// `edge.protection.transit`. See DESIGN_TRANSIT_PROTECTION.md's new §6 for
|
|
15
|
+
// the corrected hook point, the window constant, the `external-api`-only
|
|
16
|
+
// filter, and the decision table this function implements.
|
|
17
|
+
//
|
|
18
|
+
// Reuse boundary: this module imports ONLY `scanCryptoProtocol` from
|
|
19
|
+
// `../sast/crypto-protocol.js` — never `dataflow/engine.js`, never any
|
|
20
|
+
// other `src/lineage/` module. It still does NOT decide which graph EDGE a
|
|
21
|
+
// finding belongs to on its own — `resolveTransitProtectionForSite` takes a
|
|
22
|
+
// `site` (the same per-call-site object `graph-builder.js`'s edge-
|
|
23
|
+
// construction loop already has in scope) and a pre-computed
|
|
24
|
+
// `Map<file, findings[]>`, so the correlation itself is a pure, local
|
|
25
|
+
// (file, line)-proximity check, never a re-scan.
|
|
26
|
+
//
|
|
27
|
+
// `scanCryptoProtocol` itself already degrades gracefully and for free:
|
|
28
|
+
// `AGENTIC_SECURITY_NO_CRYPTO_PROTO=1` disables it (returns `[]`), and it
|
|
29
|
+
// silently returns `[]` for a file over 500KB or with no crypto-relevant
|
|
30
|
+
// content (`_isCryptoRelevant`) — both inherited here, not re-implemented.
|
|
31
|
+
//
|
|
32
|
+
|
|
33
|
+
import { scanCryptoProtocol } from '../sast/crypto-protocol.js';
|
|
34
|
+
|
|
35
|
+
// Increment 2 (DESIGN_TRANSIT_PROTECTION.md §6): a disclosed, deliberately
|
|
36
|
+
// NOT-calibrated starting value — no real fixture corpus exists yet to tune
|
|
37
|
+
// this against. A TLS-config object (`{ rejectUnauthorized: false }`) is
|
|
38
|
+
// often on the same line as, or a few lines before, the network call it
|
|
39
|
+
// configures; 10 lines is a generous but bounded window, following this
|
|
40
|
+
// package's own established line-window correlation precedent (measured
|
|
41
|
+
// directly: `engine.js`'s `dropGuardedFindings` and several other
|
|
42
|
+
// detectors use windows like `-2/+3`, `-2/+4`, `+10`, each independently
|
|
43
|
+
// chosen for its own correlation) — this constant is a NEW, independently
|
|
44
|
+
// chosen value for THIS correlation, not copied from any one of those.
|
|
45
|
+
export const TRANSIT_PROTECTION_WINDOW_LINES = 10;
|
|
46
|
+
|
|
47
|
+
// The two crypto-protocol.js families this increment's correlation cares
|
|
48
|
+
// about — confirmed against that module's own `EMITS` export and its
|
|
49
|
+
// `detectTlsNoVerify`/`detectTlsMinVersion` functions' own `_shape(...)`
|
|
50
|
+
// calls (`family: 'crypto-tls-no-verify'` / `family: 'crypto-tls-version'`).
|
|
51
|
+
const TRANSIT_FINDING_FAMILIES = new Set(['crypto-tls-no-verify', 'crypto-tls-version']);
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Increment 2's real verdict function — the `opts.resolveTransitProtection`
|
|
55
|
+
* hook `graph-builder.js` (via `coverage.js`'s default wiring) applies once
|
|
56
|
+
* per site, at the exact edge-construction point that already reads
|
|
57
|
+
* `site.destination`. `site` is one entry from `enumerateSinkSites`'s
|
|
58
|
+
* `sites[]` (post any `opts.resolveSiteDecision`/`opts.resolveDestination`
|
|
59
|
+
* overrides — this function reads `site.decision`/`site.destination`, both
|
|
60
|
+
* already resolved by the time it runs). `transitEvidenceByFile` is the
|
|
61
|
+
* SAME `Map<file, findings[]>` `scanTransitEvidence` returns — computed
|
|
62
|
+
* exactly once, by the caller (`index.js`), never re-derived here from raw
|
|
63
|
+
* file text.
|
|
64
|
+
*
|
|
65
|
+
* A deliberate, narrow first slice (DESIGN_TRANSIT_PROTECTION.md §6):
|
|
66
|
+
* `category !== 'external-api'` returns `undefined` immediately —
|
|
67
|
+
* `webhook`/`email`/`sms`/`push-notification`/`analytics`/`monitoring`/
|
|
68
|
+
* `collaboration`/`ai-*` are all real, plausible "also network" categories
|
|
69
|
+
* (named in this file's own §4 candidate list) but widening the filter is
|
|
70
|
+
* separate, deliberate, deferred scope, not silently included or excluded
|
|
71
|
+
* here.
|
|
72
|
+
*
|
|
73
|
+
* Decision table (checked in order):
|
|
74
|
+
* 1. A literal `http://` destination -> `{verdict: 'unprotected', evidenceGrade: 'code'}`
|
|
75
|
+
* unconditionally — the scheme itself is the evidence, no correlation
|
|
76
|
+
* needed.
|
|
77
|
+
* 2. A `crypto-tls-no-verify`/`crypto-tls-version` finding in the site's
|
|
78
|
+
* own file within `TRANSIT_PROTECTION_WINDOW_LINES` lines of the site's
|
|
79
|
+
* own line -> `{verdict: 'unprotected', evidenceGrade: 'code'}` — this
|
|
80
|
+
* OVERRIDES a literal `https://` scheme: a plain scheme is never
|
|
81
|
+
* sufficient evidence of protection once a nearby finding says
|
|
82
|
+
* verification was disabled (AC-04's own core property).
|
|
83
|
+
* 3. A literal `https://` destination with no such nearby finding ->
|
|
84
|
+
* `{verdict: 'protected', evidenceGrade: 'code'}`.
|
|
85
|
+
* 4. Anything else (a dynamic/unresolved destination, or a resolved
|
|
86
|
+
* destination this function has no scheme opinion about) -> `undefined`
|
|
87
|
+
* — the HONEST answer: `emptyProtection()`'s own default
|
|
88
|
+
* (`not_assessed`/`none`) already means exactly that, so this function
|
|
89
|
+
* correctly declines to overwrite it rather than manufacturing a
|
|
90
|
+
* fabricated verdict that implies more analysis happened than
|
|
91
|
+
* actually did.
|
|
92
|
+
*
|
|
93
|
+
* Never throws on a malformed `site`/`transitEvidenceByFile` — mirrors this
|
|
94
|
+
* package's own established defensiveness (`detectUnresolvedDestination`,
|
|
95
|
+
* `resolveDestination`).
|
|
96
|
+
*/
|
|
97
|
+
export function resolveTransitProtectionForSite(site, transitEvidenceByFile) {
|
|
98
|
+
if (!site || site.decision?.category !== 'external-api') return undefined;
|
|
99
|
+
|
|
100
|
+
const dest = site.destination;
|
|
101
|
+
const raw = dest?.resolutionStatus === 'literal' ? dest.literalValue : null;
|
|
102
|
+
|
|
103
|
+
if (typeof raw === 'string' && raw.startsWith('http://')) {
|
|
104
|
+
return { verdict: 'unprotected', evidenceGrade: 'code' };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const findings = transitEvidenceByFile?.get(site.file) ?? [];
|
|
108
|
+
const nearby = findings.some((f) =>
|
|
109
|
+
TRANSIT_FINDING_FAMILIES.has(f.family)
|
|
110
|
+
&& typeof site.line === 'number' && typeof f.line === 'number'
|
|
111
|
+
&& Math.abs(f.line - site.line) <= TRANSIT_PROTECTION_WINDOW_LINES);
|
|
112
|
+
if (nearby) return { verdict: 'unprotected', evidenceGrade: 'code' };
|
|
113
|
+
|
|
114
|
+
if (typeof raw === 'string' && raw.startsWith('https://')) {
|
|
115
|
+
return { verdict: 'protected', evidenceGrade: 'code' };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return undefined;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Runs `scanCryptoProtocol` over every file in `fileContents`, collecting
|
|
123
|
+
* any non-empty result into a `Map<file, findings[]>`. Never throws — a
|
|
124
|
+
* per-file detector failure is swallowed (treated as "no findings for that
|
|
125
|
+
* file") rather than aborting the whole scan, matching this package's own
|
|
126
|
+
* "best-effort, never an uncaught throw" convention for optional analysis
|
|
127
|
+
* (see `index.js`'s `buildLineageGraph`).
|
|
128
|
+
*
|
|
129
|
+
* @param {Record<string,string>} [fileContents] `{path: rawSourceString}`,
|
|
130
|
+
* the same shape `runFullScan` already threads to every other whole-file
|
|
131
|
+
* scanner. A non-string value at a given key is skipped, not coerced.
|
|
132
|
+
* @returns {Map<string, object[]>} file -> non-empty findings array. A file
|
|
133
|
+
* with zero findings (including one `scanCryptoProtocol` itself judged
|
|
134
|
+
* not crypto-relevant, or skipped for size) has NO entry — never an
|
|
135
|
+
* entry with an empty array.
|
|
136
|
+
*/
|
|
137
|
+
export function scanTransitEvidence(fileContents) {
|
|
138
|
+
const byFile = new Map();
|
|
139
|
+
for (const [file, raw] of Object.entries(fileContents ?? {})) {
|
|
140
|
+
if (typeof raw !== 'string') continue;
|
|
141
|
+
let findings;
|
|
142
|
+
try {
|
|
143
|
+
findings = scanCryptoProtocol(file, raw);
|
|
144
|
+
} catch {
|
|
145
|
+
findings = [];
|
|
146
|
+
}
|
|
147
|
+
if (findings.length) byFile.set(file, findings);
|
|
148
|
+
}
|
|
149
|
+
return byFile;
|
|
150
|
+
}
|