@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.
Files changed (209) hide show
  1. package/CHANGELOG.md +390 -0
  2. package/bin/agentic-security.js +3813 -83
  3. package/dist/1122.index.js +702 -0
  4. package/dist/{301.index.js → 1301.index.js} +2 -2
  5. package/dist/1379.index.js +591 -0
  6. package/dist/{444.index.js → 1444.index.js} +13 -4
  7. package/dist/{660.index.js → 1660.index.js} +2 -2
  8. package/dist/{700.index.js → 1700.index.js} +2 -2
  9. package/dist/{905.index.js → 1905.index.js} +3 -3
  10. package/dist/{920.index.js → 1920.index.js} +3 -3
  11. package/dist/{238.index.js → 2238.index.js} +3 -3
  12. package/dist/2271.index.js +165 -0
  13. package/dist/{985.index.js → 2376.index.js} +1260 -340
  14. package/dist/2432.index.js +793 -0
  15. package/dist/2659.index.js +93 -0
  16. package/dist/{826.index.js → 2826.index.js} +2 -2
  17. package/dist/{830.index.js → 2830.index.js} +2 -2
  18. package/dist/2923.index.js +298 -0
  19. package/dist/{1.index.js → 3001.index.js} +5 -5
  20. package/dist/{117.index.js → 3117.index.js} +3 -3
  21. package/dist/3180.index.js +307 -0
  22. package/dist/3276.index.js +117 -0
  23. package/dist/{415.index.js → 3415.index.js} +2 -2
  24. package/dist/{499.index.js → 3499.index.js} +2 -2
  25. package/dist/3518.index.js +450 -0
  26. package/dist/{526.index.js → 3526.index.js} +14 -6
  27. package/dist/{736.index.js → 3736.index.js} +4 -4
  28. package/dist/{839.index.js → 3839.index.js} +4 -4
  29. package/dist/{113.index.js → 4113.index.js} +14 -6
  30. package/dist/{265.index.js → 4265.index.js} +2 -2
  31. package/dist/{384.index.js → 4384.index.js} +3 -3
  32. package/dist/4547.index.js +268 -0
  33. package/dist/4863.index.js +422 -0
  34. package/dist/{970.index.js → 4970.index.js} +67 -3
  35. package/dist/5051.index.js +770 -0
  36. package/dist/{144.index.js → 5144.index.js} +5 -5
  37. package/dist/{333.index.js → 5333.index.js} +3 -3
  38. package/dist/5343.index.js +185 -0
  39. package/dist/5350.index.js +866 -0
  40. package/dist/5561.index.js +436 -0
  41. package/dist/{637.index.js → 5637.index.js} +29 -7
  42. package/dist/{449.index.js → 5830.index.js} +78 -14
  43. package/dist/6626.index.js +532 -0
  44. package/dist/6662.index.js +297 -0
  45. package/dist/{675.index.js → 6675.index.js} +5 -5
  46. package/dist/{730.index.js → 6730.index.js} +6 -6
  47. package/dist/6829.index.js +225 -0
  48. package/dist/6944.index.js +130 -0
  49. package/dist/{178.index.js → 7178.index.js} +26 -8
  50. package/dist/{227.index.js → 7227.index.js} +2 -2
  51. package/dist/7310.index.js +520 -0
  52. package/dist/{552.index.js → 7552.index.js} +4 -4
  53. package/dist/7709.index.js +78 -0
  54. package/dist/8218.index.js +160 -0
  55. package/dist/{476.index.js → 8476.index.js} +4 -4
  56. package/dist/{513.index.js → 8513.index.js} +5 -5
  57. package/dist/{520.index.js → 8520.index.js} +2 -2
  58. package/dist/{718.index.js → 8718.index.js} +2 -2
  59. package/dist/{752.index.js → 8752.index.js} +2 -2
  60. package/dist/8846.index.js +100 -0
  61. package/dist/{435.index.js → 9091.index.js} +580 -187
  62. package/dist/{207.index.js → 9207.index.js} +2 -2
  63. package/dist/{220.index.js → 9220.index.js} +2 -2
  64. package/dist/9390.index.js +163 -0
  65. package/dist/{503.index.js → 9503.index.js} +2 -2
  66. package/dist/{801.index.js → 9801.index.js} +2 -2
  67. package/dist/{824.index.js → 9824.index.js} +2 -2
  68. package/dist/agentic-security.mjs +16 -16
  69. package/dist/agentic-security.mjs.sha256 +1 -1
  70. package/dist/compliance-frameworks/hipaa-security-rule.json +3 -2
  71. package/package.json +23 -10
  72. package/src/compare.js +6 -1
  73. package/src/dataflow/CLAUDE.md +2 -2
  74. package/src/dataflow/catalog.js +42 -0
  75. package/src/dataflow/orm-write-catalog.js +175 -0
  76. package/src/engine.js +580 -30
  77. package/src/fix/apply-fix-service.js +1 -0
  78. package/src/history-scan.js +22 -5
  79. package/src/ir/CLAUDE.md +2 -1
  80. package/src/ir/chrome-probe.mjs +150 -0
  81. package/src/ir/parser-js.js +94 -7
  82. package/src/lineage/CLAUDE.md +1203 -0
  83. package/src/lineage/DESIGN_DESTINATION_RESOLVER.md +156 -0
  84. package/src/lineage/DESIGN_GRAPH_BUILDER.md +938 -0
  85. package/src/lineage/DESIGN_HANDLING_ANALYZER.md +355 -0
  86. package/src/lineage/DESIGN_INTRAPROCEDURAL.md +628 -0
  87. package/src/lineage/DESIGN_PATH_PROVENANCE.md +3451 -0
  88. package/src/lineage/DESIGN_QUEUE_DETAIL.md +120 -0
  89. package/src/lineage/DESIGN_REGISTRIES.md +880 -0
  90. package/src/lineage/DESIGN_STORE_DETAIL.md +143 -0
  91. package/src/lineage/DESIGN_TRANSIT_PROTECTION.md +245 -0
  92. package/src/lineage/classification.js +56 -0
  93. package/src/lineage/coverage.js +658 -0
  94. package/src/lineage/cross-repo-link.js +107 -0
  95. package/src/lineage/dataflow-graph.schema.json +184 -0
  96. package/src/lineage/decision-story.js +206 -0
  97. package/src/lineage/drift-policy.js +279 -0
  98. package/src/lineage/driver.js +135 -0
  99. package/src/lineage/engine.js +992 -0
  100. package/src/lineage/export-briefing.js +628 -0
  101. package/src/lineage/export-csv.js +62 -0
  102. package/src/lineage/export-json.js +238 -0
  103. package/src/lineage/export-privacy.js +258 -0
  104. package/src/lineage/federation-loader.js +111 -0
  105. package/src/lineage/field-identity.js +78 -0
  106. package/src/lineage/fixtures/build-flagship-fixture.mjs +272 -0
  107. package/src/lineage/fixtures/flagship-graph.json +1453 -0
  108. package/src/lineage/flow-grade.js +221 -0
  109. package/src/lineage/governance-edit.js +169 -0
  110. package/src/lineage/graph-builder.js +1114 -0
  111. package/src/lineage/graph-diff.js +431 -0
  112. package/src/lineage/graph-snapshot.js +180 -0
  113. package/src/lineage/handling-analyzer.js +168 -0
  114. package/src/lineage/ids.js +349 -0
  115. package/src/lineage/impact-assessment.js +76 -0
  116. package/src/lineage/impact-engine.js +268 -0
  117. package/src/lineage/index.js +281 -0
  118. package/src/lineage/language-coverage-tiers.js +58 -0
  119. package/src/lineage/obligation-mapping.js +126 -0
  120. package/src/lineage/obligation-predicates.js +235 -0
  121. package/src/lineage/observation-adapters.js +282 -0
  122. package/src/lineage/observation-correlation.js +622 -0
  123. package/src/lineage/observation-store.js +497 -0
  124. package/src/lineage/path-query.js +410 -0
  125. package/src/lineage/path-store.js +400 -0
  126. package/src/lineage/protection.js +53 -0
  127. package/src/lineage/recipient-profile.js +192 -0
  128. package/src/lineage/recipient-registry.js +394 -0
  129. package/src/lineage/redact-graph.js +224 -0
  130. package/src/lineage/remediation.js +417 -0
  131. package/src/lineage/resolve-destination.js +91 -0
  132. package/src/lineage/runtime-observation.js +464 -0
  133. package/src/lineage/scenario-diff.js +84 -0
  134. package/src/lineage/scenario-engine.js +251 -0
  135. package/src/lineage/scenario.js +101 -0
  136. package/src/lineage/schema.js +167 -0
  137. package/src/lineage/sink-registry.js +427 -0
  138. package/src/lineage/source-registry.js +357 -0
  139. package/src/lineage/source-seeding.js +212 -0
  140. package/src/lineage/summaries.js +590 -0
  141. package/src/lineage/transform-catalog.js +397 -0
  142. package/src/lineage/transit-protection.js +150 -0
  143. package/src/lineage/validate.js +285 -0
  144. package/src/lsp/server.js +49 -2
  145. package/src/mcp/CLAUDE.md +7 -1
  146. package/src/mcp/dataflow-tools.js +160 -0
  147. package/src/mcp/server.js +1 -1
  148. package/src/mcp/tools.js +22 -1
  149. package/src/pipeline/assurance-mode.js +64 -1
  150. package/src/pipeline/finding-schema.js +8 -1
  151. package/src/pipeline/scan-health.js +19 -1
  152. package/src/posture/CLAUDE.md +140 -0
  153. package/src/posture/accuracy-scorecard.js +60 -0
  154. package/src/posture/artifact-registry.js +76 -0
  155. package/src/posture/auditor-walkthrough.js +192 -13
  156. package/src/posture/compliance-frameworks/hipaa-security-rule.json +3 -2
  157. package/src/posture/compliance-policy.js +12 -2
  158. package/src/posture/cross-repo-memory.js +7 -2
  159. package/src/posture/fix-history.js +25 -2
  160. package/src/posture/fix-verify.js +9 -1
  161. package/src/posture/fleet.js +0 -0
  162. package/src/posture/git-history.js +13 -5
  163. package/src/posture/material-change.js +21 -2
  164. package/src/posture/mttr.js +75 -12
  165. package/src/posture/obligation-evidence-pack.js +202 -0
  166. package/src/posture/pre-incident-archaeology.js +39 -7
  167. package/src/posture/privacy-framework.js +14 -0
  168. package/src/posture/provenance/ai-authorship.js +68 -0
  169. package/src/posture/provenance/branch-entry.js +80 -0
  170. package/src/posture/provenance/cache.js +143 -0
  171. package/src/posture/provenance/confidence.js +36 -0
  172. package/src/posture/provenance/coordinator.js +786 -0
  173. package/src/posture/provenance/dag-walk.js +249 -0
  174. package/src/posture/provenance/evidence-attribution.js +59 -0
  175. package/src/posture/provenance/git-evidence.js +310 -0
  176. package/src/posture/provenance/lifecycle.js +208 -0
  177. package/src/posture/provenance/missing-control-resolver.js +137 -0
  178. package/src/posture/provenance/origin-resolver.js +342 -0
  179. package/src/posture/provenance/predicate-replay.js +133 -0
  180. package/src/posture/provenance/providers/config.js +39 -0
  181. package/src/posture/provenance/providers/github.js +62 -0
  182. package/src/posture/provenance/providers/gitlab.js +58 -0
  183. package/src/posture/provenance/repo-lineage.js +74 -0
  184. package/src/posture/provenance/sca-origin.js +139 -0
  185. package/src/posture/provenance/schema.js +255 -0
  186. package/src/posture/provenance/transitive-sca.js +147 -0
  187. package/src/posture/provenance/validate.js +30 -0
  188. package/src/posture/provenance-evidence-bundle.js +144 -0
  189. package/src/posture/remediation-ledger.js +337 -0
  190. package/src/posture/sbom-diff.js +15 -2
  191. package/src/posture/secret-history.js +10 -2
  192. package/src/posture/state-dir.js +38 -14
  193. package/src/posture/vuln-archaeology.js +8 -2
  194. package/src/pr-delta.js +25 -4
  195. package/src/report/index.js +197 -3
  196. package/src/runScan.js +34 -5
  197. package/src/sast/rate-limit.js +33 -3
  198. package/src/server/CLAUDE.md +47 -0
  199. package/src/server/graph-loader.js +141 -0
  200. package/src/server/http-server.js +325 -0
  201. package/src/server/routes.js +129 -0
  202. package/src/server/security.js +111 -0
  203. package/src/server/static-assets.js +139 -0
  204. package/src/util/git-hardening.js +128 -0
  205. package/dist/11.index.js +0 -353
  206. package/dist/259.index.js +0 -975
  207. package/dist/317.index.js +0 -300
  208. package/dist/609.index.js +0 -741
  209. package/dist/838.index.js +0 -152
@@ -0,0 +1,111 @@
1
+ // security.js — Milestone 3, sub-project Server, increment 1.
2
+ //
3
+ // Security primitives for the `explore` loopback HTTP server: session-token
4
+ // generation/comparison and Host-header validation (the T2 DNS-rebinding
5
+ // defense — see docs/DATA_FLOW_EXPLORER_THREAT_MODEL.md). No node:http
6
+ // usage here — this module is pure and independently unit-testable.
7
+
8
+ import * as crypto from 'node:crypto';
9
+
10
+ /** 32 random bytes, hex-encoded (64 hex chars) — the per-session token. */
11
+ export function generateSessionToken() {
12
+ return crypto.randomBytes(32).toString('hex');
13
+ }
14
+
15
+ /**
16
+ * Constant-time string comparison. Wraps crypto.timingSafeEqual, which
17
+ * THROWS on unequal-length buffers — guarded here the same way
18
+ * posture/integrity.js's verifyLastScan guards its own HMAC comparison
19
+ * (`stored.length !== expected.length`), which this function mirrors as
20
+ * its template. Never throws; returns false on any malformed input.
21
+ */
22
+ export function constantTimeEqual(a, b) {
23
+ if (typeof a !== 'string' || typeof b !== 'string') return false;
24
+ const bufA = Buffer.from(a, 'utf8');
25
+ const bufB = Buffer.from(b, 'utf8');
26
+ if (bufA.length !== bufB.length) return false;
27
+ try {
28
+ return crypto.timingSafeEqual(bufA, bufB);
29
+ } catch {
30
+ return false;
31
+ }
32
+ }
33
+
34
+ // Loopback hostnames this server ever legitimately answers to. Anything
35
+ // else in a Host header is either a stray/misconfigured client or a
36
+ // DNS-rebinding attempt from a hostile page — reject it (T2).
37
+ const LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '::1']);
38
+
39
+ /**
40
+ * Validates a request's `Host` header against the port the server is
41
+ * actually listening on. Accepts only `127.0.0.1`/`localhost`/`::1`
42
+ * (`::1` written either bare or bracketed, `[::1]`), each carrying the
43
+ * expected port explicitly — this is the ENTIRE T2 (DNS rebinding)
44
+ * defense: a hostile page that points a fetch() at 127.0.0.1:<port> still
45
+ * sends the ATTACKER'S origin's Host header value (its own hostname), not
46
+ * "127.0.0.1", so this check rejects it regardless of same-origin policy
47
+ * quirks in any particular browser.
48
+ *
49
+ * A bare host with no port suffix is accepted ONLY when expectedPort is
50
+ * the default HTTP port (80) — browsers omit :80 for a default-port
51
+ * http:// request. In practice this server always listens on a
52
+ * non-default port, so that branch is effectively unreachable but is kept
53
+ * for RFC-correctness rather than special-cased away.
54
+ *
55
+ * @param {string|undefined} hostHeader
56
+ * @param {number} expectedPort
57
+ * @returns {boolean}
58
+ */
59
+ export function isValidHost(hostHeader, expectedPort) {
60
+ if (typeof hostHeader !== 'string' || hostHeader.length === 0) return false;
61
+ if (typeof expectedPort !== 'number' || !Number.isInteger(expectedPort) || expectedPort <= 0) return false;
62
+
63
+ const host = hostHeader.trim();
64
+ if (host.length !== hostHeader.length) return false; // reject leading/trailing whitespace outright
65
+ if (/\s/.test(host)) return false; // no embedded whitespace/control chars in a well-formed Host header
66
+
67
+ let hostname;
68
+ let port = null;
69
+
70
+ if (host.startsWith('[')) {
71
+ // Bracketed IPv6 literal form: [::1] or [::1]:port
72
+ const closeIdx = host.indexOf(']');
73
+ if (closeIdx === -1) return false;
74
+ hostname = host.slice(1, closeIdx); // strip brackets
75
+ const rest = host.slice(closeIdx + 1);
76
+ if (rest.length) {
77
+ if (!rest.startsWith(':')) return false;
78
+ const portStr = rest.slice(1);
79
+ if (!/^\d+$/.test(portStr)) return false;
80
+ port = Number(portStr);
81
+ }
82
+ } else {
83
+ const parts = host.split(':');
84
+ if (parts.length === 1) {
85
+ hostname = parts[0];
86
+ } else if (parts.length === 2) {
87
+ hostname = parts[0];
88
+ if (!/^\d+$/.test(parts[1])) return false;
89
+ port = Number(parts[1]);
90
+ } else {
91
+ // More than one colon with no brackets: either a malformed header or
92
+ // a bare (unbracketed) IPv6 literal, which is not valid Host syntax
93
+ // (RFC 7230 §5.4 requires brackets for an IPv6 literal). Reject.
94
+ return false;
95
+ }
96
+ }
97
+
98
+ hostname = hostname.toLowerCase();
99
+ if (!LOOPBACK_HOSTS.has(hostname)) return false;
100
+
101
+ if (port === null) return expectedPort === 80;
102
+ return port === expectedPort;
103
+ }
104
+
105
+ // This server serves JSON only in this increment (no HTML, no inline
106
+ // script/style, no static assets) — `frontend/`'s own static assets are a
107
+ // later increment's (Wire's) job, not S1's. `default-src 'none'` is
108
+ // therefore the correct, maximally-restrictive policy: nothing this
109
+ // server ever returns should load or execute ANY sub-resource. Revisit
110
+ // only when a future increment starts serving HTML/JS itself.
111
+ export const CSP_HEADER_VALUE = "default-src 'none'; frame-ancestors 'none'";
@@ -0,0 +1,139 @@
1
+ // static-assets.js — Milestone 3, sub-project Wire.
2
+ //
3
+ // Confines the `explore` server's new same-origin static-asset surface to an
4
+ // explicit ALLOWLIST of `frontend/`'s own real, servable files. This is the
5
+ // first place scanner/src/server/ ever reads arbitrary requested paths off
6
+ // disk — S1's five JSON endpoints never touched the filesystem beyond the
7
+ // one already-loaded, already-verified graph — so path confinement here is
8
+ // genuinely load-bearing, not decorative (see the M3-Wire scoping doc's
9
+ // Decision 2, and the threat-model doc's T4 entry).
10
+ //
11
+ // `resolveStaticAsset()` is a PURE function: no fs/http access anywhere in
12
+ // this file, fully unit-testable in isolation. http-server.js is the only
13
+ // caller that actually reads a resolved file off disk.
14
+
15
+ import * as path from 'node:path';
16
+ import { fileURLToPath } from 'node:url';
17
+
18
+ // Located the SAME way scanner/src/mcp/server.js locates files relative to
19
+ // its own module (path.dirname(fileURLToPath(import.meta.url))) — the real,
20
+ // existing precedent for this pattern in this codebase, not a new one.
21
+ // scanner/src/server/ -> ../../../frontend, computed (not guessed) and
22
+ // confirmed to resolve to the real frontend/ directory.
23
+ const _here = path.dirname(fileURLToPath(import.meta.url));
24
+ export const FRONTEND_ROOT = path.resolve(_here, '..', '..', '..', 'frontend');
25
+
26
+ export const CONTENT_TYPE_MAP = Object.freeze({
27
+ '.html': 'text/html; charset=utf-8',
28
+ '.js': 'text/javascript; charset=utf-8',
29
+ '.css': 'text/css; charset=utf-8',
30
+ });
31
+
32
+ // Same-origin-permitting CSP for the static/HTML surface — deliberately
33
+ // DIFFERENT from security.js's CSP_HEADER_VALUE ("default-src 'none'"),
34
+ // which stays correct for the JSON-only /api/v1/* routes (unchanged). This
35
+ // one needs to let the page load its OWN same-origin script/style and make
36
+ // its OWN same-origin fetch() calls, while still denying everything
37
+ // cross-origin — no remote scripts/fonts/analytics, matching
38
+ // frontend/README.md's own "no CDN scripts, remote fonts, analytics" rule.
39
+ //
40
+ // Grepped across the whole frontend/ tree for any inline <script>/
41
+ // style="..." attribute/<style> tag beyond the one index.html inline
42
+ // <script type="module"> block this increment moves into main.js (see
43
+ // item 5 of the plan): none found (confirmed this session, 2026-09-01).
44
+ // So a strict script-src 'self'/style-src 'self' needs no 'unsafe-inline'
45
+ // exception — do not add one without re-running that grep.
46
+ export const STATIC_CSP_HEADER_VALUE =
47
+ "default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'";
48
+
49
+ // Deliberately restrictive character class (no backslash, no space, no
50
+ // percent sign, no null) — every real filename in frontend/'s inventory is
51
+ // plain ASCII with letters/digits/hyphen/underscore/dot/slash, and keeping
52
+ // the allowlist regex this narrow means an unexpected character in a
53
+ // request path fails allowlisting by default rather than being silently
54
+ // accepted and handed to a platform-specific path.join() later.
55
+ const SRC_JS_RE = /^src\/[A-Za-z0-9/_.-]+\.js$/;
56
+ const STYLES_CSS_RE = /^styles\/[A-Za-z0-9_.-]+\.css$/; // top-level only under styles/, no nested subdirectories — matches the real, current inventory
57
+
58
+ /**
59
+ * Resolves a request pathname (e.g. `/`, `/src/app.js`, `/styles/tokens.css`)
60
+ * against `frontend/`'s explicit servable-file allowlist. Returns
61
+ * `{ok:true, relativePath, contentType}` or `{ok:false, reason}`.
62
+ *
63
+ * Deliberately an ALLOWLIST, not merely a `../`-traversal guard: `test/`,
64
+ * `scripts/`, `package.json`, `README.md`, `CLAUDE.md`, and `.gitignore` are
65
+ * all real files that exist under FRONTEND_ROOT on disk and must NEVER be
66
+ * served — a naive "stays inside frontend/" confinement check would still
67
+ * happily serve every one of them.
68
+ */
69
+ export function resolveStaticAsset(requestPath) {
70
+ if (typeof requestPath !== 'string' || requestPath.length === 0) {
71
+ return { ok: false, reason: 'invalid-path' };
72
+ }
73
+ if (requestPath.includes('\0')) {
74
+ return { ok: false, reason: 'null-byte' };
75
+ }
76
+ // Absolute-URL-looking input: a scheme (`http://...`) or a
77
+ // protocol-relative `//host/...` — neither is a valid same-origin
78
+ // pathname this server should ever try to resolve.
79
+ if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(requestPath) || requestPath.startsWith('//')) {
80
+ return { ok: false, reason: 'absolute-url' };
81
+ }
82
+ if (!requestPath.startsWith('/')) {
83
+ return { ok: false, reason: 'invalid-path' };
84
+ }
85
+
86
+ let decoded;
87
+ try {
88
+ decoded = decodeURIComponent(requestPath);
89
+ } catch {
90
+ return { ok: false, reason: 'malformed-encoding' };
91
+ }
92
+ if (decoded.includes('\0')) {
93
+ // Catches a %00-encoded null byte the raw-string check above can't see.
94
+ return { ok: false, reason: 'null-byte' };
95
+ }
96
+
97
+ if (decoded === '/' || decoded === '') {
98
+ return { ok: true, relativePath: 'index.html', contentType: CONTENT_TYPE_MAP['.html'] };
99
+ }
100
+ if (decoded.startsWith('//')) {
101
+ return { ok: false, reason: 'absolute-url' };
102
+ }
103
+
104
+ const candidate = decoded.slice(1); // strip the single leading '/'
105
+
106
+ // Normalize using POSIX rules regardless of host OS — this is a URL path,
107
+ // never a platform filesystem path, and must be treated as forward-slash
108
+ // segments even on a host where path.normalize would use backslashes.
109
+ const normalized = path.posix.normalize(candidate);
110
+
111
+ // Re-check for a traversal escape AFTER normalization — never trust
112
+ // normalize() alone to have removed a traversal attempt (a classic
113
+ // traversal-bypass class): explicitly reject anything that still IS, or
114
+ // still CONTAINS, a `..` segment, or that normalized to an absolute path.
115
+ if (
116
+ normalized === '..' ||
117
+ normalized === '.' ||
118
+ normalized.startsWith('../') ||
119
+ normalized.includes('/../') ||
120
+ normalized.endsWith('/..') ||
121
+ normalized.startsWith('/')
122
+ ) {
123
+ return { ok: false, reason: 'path-traversal' };
124
+ }
125
+
126
+ // --- The actual ALLOWLIST (default: deny) ---
127
+
128
+ if (normalized === 'index.html') {
129
+ return { ok: true, relativePath: normalized, contentType: CONTENT_TYPE_MAP['.html'] };
130
+ }
131
+ if (SRC_JS_RE.test(normalized)) {
132
+ return { ok: true, relativePath: normalized, contentType: CONTENT_TYPE_MAP['.js'] };
133
+ }
134
+ if (STYLES_CSS_RE.test(normalized)) {
135
+ return { ok: true, relativePath: normalized, contentType: CONTENT_TYPE_MAP['.css'] };
136
+ }
137
+
138
+ return { ok: false, reason: 'not-allowlisted' };
139
+ }
@@ -0,0 +1,128 @@
1
+ // Hardening for every `git` subprocess call this scanner makes against a
2
+ // SCANNED repository it does not control — i.e. any git invocation whose
3
+ // `cwd` (or `-C <dir>`) is a project the scanner was asked to scan, as
4
+ // opposed to this project's own trusted checkout.
5
+ //
6
+ // Verified RCE (second independent Finding Provenance PRD audit,
7
+ // FR-PROV-024 / PRD Section 8 "never run repository hooks or untrusted
8
+ // build scripts"): git-evidence.js's `_run` invoked `git` with no config
9
+ // hardening at all. A hostile repo's `.git/config` can set `core.fsmonitor`
10
+ // to point at an attacker script; git executes it on an ordinary READ-ONLY
11
+ // command like `git status --porcelain` — no clone, no checkout, no
12
+ // deliberate command needed, just `getRepoState()` reading repo state.
13
+ // Reproduced against this exact repro shape before this module existed:
14
+ // `getRepoState()` alone wrote a marker file outside the repo.
15
+ //
16
+ // Four independent hostile-config surfaces, each closed by a different
17
+ // flag/env var (do not assume one covers another — a same-class RCE
18
+ // survived the first round of this hardening precisely because
19
+ // `--no-textconv` was assumed to cover `git diff` the same way it covers
20
+ // `git show`/`git log -p`/`git blame`, and it does not):
21
+ // - `core.fsmonitor` -> fires on `git status` (and other porcelain
22
+ // commands that consult the index). Closed by
23
+ // `-c core.fsmonitor=` (empty value disables it).
24
+ // - `core.hooksPath` -> redirects git's hook lookup to an
25
+ // attacker-controlled directory (pre-commit,
26
+ // post-checkout, ...). None of THIS module's
27
+ // read-only operations should fire a hook, but a
28
+ // caller elsewhere in the tree that does invoke a
29
+ // hook-shaped command (checkout, commit) inherits
30
+ // the same exposure — hardened uniformly rather
31
+ // than relying on each call site to reason about
32
+ // whether its own command can trigger a hook.
33
+ // Closed by `-c core.hooksPath=/dev/null`
34
+ // (verified: git tries to stat
35
+ // `/dev/null/<hookname>`, which is not a
36
+ // directory, so hook lookup fails closed — this
37
+ // is NOT relying on /dev/null being an empty
38
+ // *file*, it works because it isn't a directory).
39
+ // - `.gitattributes` -> a `diff=<name>` attribute + a matching
40
+ // textconv driver `diff.<name>.textconv` config key points a text
41
+ // filter at an attacker script; fires on any
42
+ // command that renders blob/diff CONTENT (`git
43
+ // show`/`git diff`/`git log -p`/`git log -L`/
44
+ // `git blame`) unless `--no-textconv` is passed.
45
+ // Verified per-subcommand: `git show -s` (no
46
+ // content shown) and `git show <sha>:<path>`
47
+ // (blob cat, not a diff) were NOT exploitable in
48
+ // this git version, but `git show -U0`, `git log
49
+ // -L`, and `git blame` all were.
50
+ // - `.git/config` / an EXTERNAL diff driver — `.gitattributes`
51
+ // `.gitattributes` `diff=<name>` + `.git/config [diff "<name>"]
52
+ // external diff driver command=<script>`, or the repo-local/global
53
+ // `diff.external` config key — is a DIFFERENT
54
+ // mechanism from the textconv driver above and is
55
+ // NOT closed by `--no-textconv`. VERIFIED: `git
56
+ // -c core.fsmonitor= -c core.hooksPath=/dev/null
57
+ // diff --unified=0 --no-textconv <ref>...HEAD`
58
+ // still runs the attacker's `diff.evil.command`
59
+ // script — `--no-textconv` only suppresses the
60
+ // TEXTCONV driver, and `git diff` (unlike `git
61
+ // show`/`git log -p`/`git blame`, which were all
62
+ // verified safe with just `--no-textconv`) honours
63
+ // an external diff driver by default regardless.
64
+ // Closed by `--no-ext-diff`, which must be passed
65
+ // explicitly on every `git diff` invocation (same
66
+ // reason `--no-textconv` isn't a `-c` flag: it's a
67
+ // diff-machinery option, not repo config). This
68
+ // was the live RCE a second review found after the
69
+ // first round of this hardening shipped —
70
+ // material-change.js's `classifyGitDiff` (the real
71
+ // entry point for `/scan --diff`) had
72
+ // `--no-textconv` but not `--no-ext-diff` and was
73
+ // still exploitable end-to-end.
74
+ //
75
+ // A FIFTH surface is known but not exploitable through any call site in this
76
+ // codebase today, so it is documented rather than closed: a `clean` smudge
77
+ // filter (`.gitattributes` `filter=<name>` + `filter.<name>.clean`) fires on
78
+ // a WORKTREE diff (e.g. `git diff --name-only HEAD` with no `<ref>` on the
79
+ // other side) and has no git flag to disable it at all (unlike textconv/
80
+ // ext-diff). Every `git diff` call site in this codebase diffs two refs
81
+ // (`<ref>...HEAD`), never the worktree against HEAD, so nothing here hits
82
+ // it — but a future worktree-diff call site would silently reintroduce this
83
+ // exact vulnerability class and must not assume `hardenGitArgs` covers it.
84
+ //
85
+ // `GIT_CONFIG_NOSYSTEM=1` additionally blocks a SYSTEM-level git config
86
+ // (outside any repository, e.g. /etc/gitconfig) from re-introducing a
87
+ // hostile setting the `-c` flags above didn't anticipate; `GIT_TERMINAL_PROMPT=0`
88
+ // stops a git invocation from ever blocking on an interactive credential
89
+ // prompt (a hostile repo pointing `origin`/a submodule at a URL that
90
+ // prompts). Both are environment variables, not `-c` flags — git does not
91
+ // expose either as repo-local config.
92
+ //
93
+ // Every `git` subprocess call this scanner makes against a scan target's
94
+ // repository MUST route its args through `hardenGitArgs` and its env
95
+ // through `hardenGitEnv`. A `git show`/`git diff`/`git log -p`/`git log -L`/
96
+ // `git blame` invocation must ALSO pass `--no-textconv` explicitly (it is
97
+ // not a `-c` config value, so it isn't folded into `GIT_HARDENED_CONFIG_ARGS`
98
+ // — it must appear in the invocation's own args, after the config args), and
99
+ // a `git diff` invocation must ADDITIONALLY pass `--no-ext-diff` (a
100
+ // different flag for a different surface — see above; `--no-textconv` does
101
+ // not imply it).
102
+
103
+ export const GIT_HARDENED_CONFIG_ARGS = Object.freeze([
104
+ '-c', 'core.fsmonitor=',
105
+ '-c', 'core.hooksPath=/dev/null',
106
+ ]);
107
+
108
+ export const GIT_HARDENED_ENV = Object.freeze({
109
+ GIT_CONFIG_NOSYSTEM: '1',
110
+ GIT_TERMINAL_PROMPT: '0',
111
+ });
112
+
113
+ // Prepend the hardening `-c` flags to a git argv. These are GLOBAL options
114
+ // and must appear before the subcommand, which is why this always prepends
115
+ // rather than appending — a `-c` after the subcommand name is parsed as a
116
+ // positional argument to that subcommand, not a global option.
117
+ export function hardenGitArgs(args) {
118
+ return [...GIT_HARDENED_CONFIG_ARGS, ...(Array.isArray(args) ? args : [])];
119
+ }
120
+
121
+ // Build the `env` option for execFileSync/spawnSync. Node's `env` option
122
+ // REPLACES the child's environment rather than merging with it, so this
123
+ // always spreads `process.env` first — passing `hardenGitEnv()` with no
124
+ // argument must be behaviourally identical to inheriting the parent
125
+ // environment plus the two hardening vars, never a stripped-down one.
126
+ export function hardenGitEnv(extraEnv) {
127
+ return { ...process.env, ...GIT_HARDENED_ENV, ...(extraEnv || {}) };
128
+ }