@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,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
|
+
}
|