@clear-capabilities/agentic-security-scanner 0.144.0 → 0.145.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 +251 -0
- package/bin/agentic-security.js +294 -3
- package/dist/113.index.js +11 -3
- package/dist/178.index.js +24 -6
- package/dist/271.index.js +165 -0
- package/dist/384.index.js +1 -1
- package/dist/435.index.js +22 -0
- package/dist/444.index.js +11 -2
- package/dist/449.index.js +76 -12
- package/dist/526.index.js +11 -3
- package/dist/637.index.js +27 -5
- package/dist/970.index.js +65 -1
- package/dist/agentic-security.mjs +9 -9
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +14 -8
- package/src/compare.js +6 -1
- package/src/dataflow/CLAUDE.md +1 -1
- package/src/engine.js +488 -29
- package/src/fix/apply-fix-service.js +1 -0
- package/src/history-scan.js +22 -5
- package/src/ir/CLAUDE.md +1 -1
- package/src/lsp/server.js +49 -2
- package/src/mcp/tools.js +20 -0
- package/src/pipeline/assurance-mode.js +64 -1
- package/src/pipeline/finding-schema.js +8 -1
- package/src/posture/CLAUDE.md +121 -0
- package/src/posture/accuracy-scorecard.js +60 -0
- package/src/posture/artifact-registry.js +24 -0
- package/src/posture/auditor-walkthrough.js +116 -13
- 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/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/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/util/git-hardening.js +128 -0
package/src/report/index.js
CHANGED
|
@@ -5,6 +5,19 @@ import { alertFace, approveFace } from './mascot.js';
|
|
|
5
5
|
import { SCANNER_VERSION } from '../posture/version.js';
|
|
6
6
|
import { proofBlock } from '../posture/proof-artifact.js';
|
|
7
7
|
import { applyLegacyCompat, legacyFieldDeprecationNotice } from '../pipeline/legacy-compat.js';
|
|
8
|
+
// Finding Provenance (M0/M1). NOTE the name: `findingProvenance` is the
|
|
9
|
+
// git-origin record from posture/provenance/. It is NOT `finding.provenance`
|
|
10
|
+
// (posture/ai-code-fingerprint.js's AI-authorship signal, normalized a few
|
|
11
|
+
// hundred lines below) and NOT `supplyChainEntry.provenance`
|
|
12
|
+
// (sca/sigstore-verify.js's SLSA/Sigstore build attestation). Three unrelated
|
|
13
|
+
// things, three distinct keys — do not collapse them.
|
|
14
|
+
import { redactFindingProvenance, sanitizeForTerminal } from '../posture/provenance/schema.js';
|
|
15
|
+
// Re-exported: FR-PROV-026. Lives in provenance/schema.js (shared with
|
|
16
|
+
// posture/auditor-walkthrough.js, a second CLI renderer of the same
|
|
17
|
+
// untrusted fields — see that module's header for why it lives there
|
|
18
|
+
// rather than here); re-exported so `explainProvenance`'s own module keeps
|
|
19
|
+
// being the discoverable home for provenance-text callers.
|
|
20
|
+
export { sanitizeForTerminal };
|
|
8
21
|
|
|
9
22
|
const SEV_RANK = { critical: 0, high: 1, medium: 2, low: 3, info: 4 };
|
|
10
23
|
const SEV_TO_SARIF = { critical: 'error', high: 'error', medium: 'warning', low: 'note', info: 'none' };
|
|
@@ -84,6 +97,62 @@ function explainParts(f, { verbose = false } = {}) {
|
|
|
84
97
|
return { why, how, fix: fix.replace(/\s+/g, ' ').trim(), fixCode };
|
|
85
98
|
}
|
|
86
99
|
|
|
100
|
+
// Human-readable rendering of a finding's git-origin provenance (FR-PROV
|
|
101
|
+
// output surface). Returns null when the finding carries none — a caller
|
|
102
|
+
// prints the block or omits it, and never has to know the schema.
|
|
103
|
+
//
|
|
104
|
+
// Every one of the six TERMINAL statuses gets a line of its own, and the
|
|
105
|
+
// non-`complete` ones say WHY in the status word itself rather than rendering
|
|
106
|
+
// an empty origin: that is the whole reason the status enum exists (see
|
|
107
|
+
// posture/provenance/coordinator.js's header). `Method` and `Confidence` are
|
|
108
|
+
// unconditional — a block that omitted them for a degraded status would read
|
|
109
|
+
// as "origin unknown" when the honest statement is "resolved by <method> to
|
|
110
|
+
// <confidence>". Nothing here throws on a partially-populated object; every
|
|
111
|
+
// field access is optional-chained or defaulted, because a provenance record
|
|
112
|
+
// that survived a failure path is exactly the input this has to render.
|
|
113
|
+
export function explainProvenance(f) {
|
|
114
|
+
const fp = f && f.findingProvenance;
|
|
115
|
+
if (!fp) return null;
|
|
116
|
+
const short = (v) => String(v || '').slice(0, 7);
|
|
117
|
+
const day = (v) => String(v || '').slice(0, 10);
|
|
118
|
+
const lines = [];
|
|
119
|
+
const o = fp.findingOrigin;
|
|
120
|
+
if (fp.status === 'complete' && o) {
|
|
121
|
+
lines.push(`Introduced: ${short(o.commit)} • ${day(o.authorDate)} • ${sanitizeForTerminal(o.authorName) || 'unknown'}`);
|
|
122
|
+
const bi = fp.branchIntroduction;
|
|
123
|
+
if (bi && bi.commit !== o.commit) {
|
|
124
|
+
// relationship is an internal enum ('merge'/'direct', see
|
|
125
|
+
// branch-entry.js) never sourced from untrusted git text — wrapped
|
|
126
|
+
// anyway for defense in depth and consistency with authorName above.
|
|
127
|
+
lines.push(`Branch entry: ${short(bi.commit)} • ${sanitizeForTerminal(bi.relationship) || 'unknown relationship'}`);
|
|
128
|
+
}
|
|
129
|
+
} else if (fp.status === 'partial') {
|
|
130
|
+
lines.push(`Origin: EARLIEST OBSERVABLE${o ? ' ' + short(o.commit) : ''}`);
|
|
131
|
+
} else if (fp.status === 'uncommitted') {
|
|
132
|
+
lines.push('Origin: UNCOMMITTED (working tree only)');
|
|
133
|
+
} else if (fp.status === 'not_available') {
|
|
134
|
+
lines.push('Origin: NOT AVAILABLE');
|
|
135
|
+
} else if (fp.status === 'error') {
|
|
136
|
+
lines.push('Origin: ERROR resolving provenance');
|
|
137
|
+
} else if (fp.status === 'budget_exhausted') {
|
|
138
|
+
lines.push('Origin: BUDGET EXHAUSTED before resolution completed');
|
|
139
|
+
} else {
|
|
140
|
+
// An unrecognised status is reported as itself, not silently dropped — a
|
|
141
|
+
// future status added to the enum must be visible here on the day it
|
|
142
|
+
// ships, even before this renderer learns a nicer wording for it.
|
|
143
|
+
lines.push(`Origin: ${String(fp.status || 'unknown').toUpperCase()}`);
|
|
144
|
+
}
|
|
145
|
+
if (fp.firstObserved) {
|
|
146
|
+
lines.push(`First observed: ${fp.firstObserved.scanId || 'unknown scan'} • ${day(fp.firstObserved.observedAt)}`);
|
|
147
|
+
}
|
|
148
|
+
lines.push(`Method: ${fp.method || 'none'}`);
|
|
149
|
+
lines.push(`Confidence: ${String(fp.confidence?.level || 'unknown').toUpperCase()}`);
|
|
150
|
+
if (Array.isArray(fp.limitations) && fp.limitations.length) {
|
|
151
|
+
lines.push(`Limitations: ${fp.limitations.join('; ')}`);
|
|
152
|
+
}
|
|
153
|
+
return lines.join('\n');
|
|
154
|
+
}
|
|
155
|
+
|
|
87
156
|
function fingerprint(f){
|
|
88
157
|
const s = `${f.file}:${f.line||f.source?.line||0}:${f.vuln||f.type||''}`;
|
|
89
158
|
return crypto.createHash('sha256').update(s).digest('hex').slice(0, 16);
|
|
@@ -108,6 +177,24 @@ export function _remediationOf(f) {
|
|
|
108
177
|
return null;
|
|
109
178
|
}
|
|
110
179
|
|
|
180
|
+
// Redacted passthrough for the git-origin provenance record (FR-PROV output
|
|
181
|
+
// surface). Author EMAIL is PII and is withheld by default EVERYWHERE — raw
|
|
182
|
+
// JSON included, since `toJSON` derives from this same function and an email
|
|
183
|
+
// in a committed report artifact is a leak nobody opted into. Set
|
|
184
|
+
// AGENTIC_SECURITY_INCLUDE_AUTHOR_EMAIL=1 to include it (read per call, not
|
|
185
|
+
// cached at module load, so a test or a wrapper can set it per invocation).
|
|
186
|
+
// `authorName` is deliberately NOT redacted by default: it is already the
|
|
187
|
+
// value the existing `introducedBy` field has carried for releases. Set
|
|
188
|
+
// AGENTIC_SECURITY_PSEUDONYMIZE_AUTHORS=1 (--pseudonymize-authors) to replace
|
|
189
|
+
// it with a stable Contributor-XXXXXXXX id instead — PRD Section 8.
|
|
190
|
+
function _normalizedProvenance(f) {
|
|
191
|
+
if (!f || !f.findingProvenance) return null;
|
|
192
|
+
return redactFindingProvenance(f.findingProvenance, {
|
|
193
|
+
includeEmail: process.env.AGENTIC_SECURITY_INCLUDE_AUTHOR_EMAIL === '1',
|
|
194
|
+
pseudonymize: process.env.AGENTIC_SECURITY_PSEUDONYMIZE_AUTHORS === '1',
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
111
198
|
export function normalizeFindings(scan){
|
|
112
199
|
const out = [];
|
|
113
200
|
// Feat-4: filter findings via custom suppressions, recording the suppression
|
|
@@ -250,6 +337,10 @@ export function normalizeFindings(scan){
|
|
|
250
337
|
cloneClusterSize: typeof f.cloneClusterSize === 'number' ? f.cloneClusterSize : null,
|
|
251
338
|
provenance: f.provenance || null,
|
|
252
339
|
provenanceScore: typeof f.provenanceScore === 'number' ? f.provenanceScore : null,
|
|
340
|
+
// posture/provenance/coordinator.js#annotateGitProvenance — WHICH COMMIT
|
|
341
|
+
// introduced this finding. A different question, and a different field,
|
|
342
|
+
// from the two lines above it (see the import comment at the top).
|
|
343
|
+
findingProvenance: _normalizedProvenance(f),
|
|
253
344
|
typeNarrowed: f.typeNarrowed || null,
|
|
254
345
|
strideCategory: f.strideCategory || null,
|
|
255
346
|
personaScores: f.personaScores || null,
|
|
@@ -349,6 +440,15 @@ export function normalizeFindings(scan){
|
|
|
349
440
|
commit: s.commit || null,
|
|
350
441
|
historical: s._historical === true,
|
|
351
442
|
description: s.description || null,
|
|
443
|
+
// Second independent Finding Provenance PRD audit (Task 7, item 4):
|
|
444
|
+
// `stableId` was emitted for the SAST channel only (below), so a
|
|
445
|
+
// consumer of a secrets/logic/SCA finding could not recompute
|
|
446
|
+
// `findingProvenance.evidenceDigest` (it binds `stableId` as its first
|
|
447
|
+
// input — coordinator.js's `computeDigest`) — the exact case this
|
|
448
|
+
// channel needs it for, since `annotateStableIds(aSecrets)` (engine.js)
|
|
449
|
+
// already backfills a real one before provenance resolution runs.
|
|
450
|
+
stableId: s.stableId || null,
|
|
451
|
+
findingProvenance: _normalizedProvenance(s),
|
|
352
452
|
});
|
|
353
453
|
}
|
|
354
454
|
for (const lv of (scan.logicVulns||[])) {
|
|
@@ -376,6 +476,15 @@ export function normalizeFindings(scan){
|
|
|
376
476
|
ecosystem: lv.ecosystem || null,
|
|
377
477
|
license: lv.license || null,
|
|
378
478
|
description: lv.description || null,
|
|
479
|
+
// Second independent Finding Provenance PRD audit (Task 7, item 4) —
|
|
480
|
+
// same reasoning as the secrets channel above. Note: only the
|
|
481
|
+
// `blameableLogic` subset (engine.js) gets a real backfilled stableId
|
|
482
|
+
// via `annotateStableIds`; the three synthetic-line producers
|
|
483
|
+
// (license-policy:/deploy-platform:/stack-playbook:) never go through
|
|
484
|
+
// that backfill and stay `null` here — honest, since they also stay on
|
|
485
|
+
// the permanent not_available provenance path (posture/CLAUDE.md).
|
|
486
|
+
stableId: lv.stableId || null,
|
|
487
|
+
findingProvenance: _normalizedProvenance(lv),
|
|
379
488
|
});
|
|
380
489
|
}
|
|
381
490
|
for (const sc of (scan.supplyChain||[])) {
|
|
@@ -425,6 +534,20 @@ export function normalizeFindings(scan){
|
|
|
425
534
|
toxicity: sc.toxicityScore ?? null,
|
|
426
535
|
toxicityFactors: sc.toxicityFactors || null,
|
|
427
536
|
toxicityLabel: sc.toxicityLabel || null,
|
|
537
|
+
// Git-origin provenance for the dependency declaration (which commit
|
|
538
|
+
// moved the declared version into the advisory's vulnerable range).
|
|
539
|
+
// Distinct from `sc.provenance`, which sca/sigstore-verify.js uses for
|
|
540
|
+
// the package's Sigstore/SLSA build attestation — that one is NOT
|
|
541
|
+
// carried here today and must not be conflated with this field.
|
|
542
|
+
// Second independent Finding Provenance PRD audit (Task 7, item 4) —
|
|
543
|
+
// same reasoning as the secrets/logic channels above. Only entries
|
|
544
|
+
// routed through annotateGitProvenance (engine.js's `directDeps`/
|
|
545
|
+
// `transitiveDeps`, `isSca`/`isTransitiveSca` in coordinator.js) get a
|
|
546
|
+
// real backfilled `scaStableId`; an entry that was never routed
|
|
547
|
+
// through it (e.g. `unpinned_dep`/`no_lockfile` types) honestly stays
|
|
548
|
+
// `null` here rather than fabricating one.
|
|
549
|
+
stableId: sc.stableId || null,
|
|
550
|
+
findingProvenance: _normalizedProvenance(sc),
|
|
428
551
|
});
|
|
429
552
|
}
|
|
430
553
|
// FR-108: backfill deprecated field names from their current replacements
|
|
@@ -676,15 +799,22 @@ export function toCSV(scan){
|
|
|
676
799
|
const s = String(v);
|
|
677
800
|
return /[",\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s;
|
|
678
801
|
};
|
|
679
|
-
|
|
802
|
+
// FR-PROV-018: a nested findingProvenance object has no natural CSV
|
|
803
|
+
// representation, so this is a deliberate flattening (status/commit/
|
|
804
|
+
// authorDate/confidence), not full fidelity. Appended after the existing
|
|
805
|
+
// columns so a spreadsheet already keyed on column position is unaffected.
|
|
806
|
+
const header = ['id', 'severity', 'vuln', 'cwe', 'cvss', 'owasp', 'file', 'line', 'confidence', 'reachable', 'kind', 'snippet', 'provenanceStatus', 'provenanceCommit', 'provenanceAuthorDate', 'provenanceConfidence'];
|
|
680
807
|
const rows = [header.join(',')];
|
|
681
808
|
for (const f of findings) {
|
|
809
|
+
const fp = f.findingProvenance;
|
|
682
810
|
rows.push([
|
|
683
811
|
esc(f.id), esc(f.severity), esc(f.vuln), esc(f.cwe), esc(f.cvss || ''),
|
|
684
812
|
esc(f.owasp || ''), esc(f.file), esc(f.line),
|
|
685
813
|
esc(f.confidence == null ? '' : f.confidence.toFixed(3)),
|
|
686
814
|
esc(f.reachable == null ? '' : f.reachable),
|
|
687
815
|
esc(f.kind), esc((f.snippet || '').slice(0, 200)),
|
|
816
|
+
esc(fp?.status || ''), esc(fp?.findingOrigin?.commit || ''),
|
|
817
|
+
esc(fp?.findingOrigin?.authorDate || ''), esc(fp?.confidence?.level || ''),
|
|
688
818
|
].join(','));
|
|
689
819
|
}
|
|
690
820
|
return rows.join('\n');
|
|
@@ -732,6 +862,15 @@ export function toJUnit(scan, meta={}){
|
|
|
732
862
|
return lines.join('\n');
|
|
733
863
|
}
|
|
734
864
|
|
|
865
|
+
// FR-PROV-026: length of the Markdown code fence needed to safely wrap
|
|
866
|
+
// `text` without it being able to break out via an embedded backtick run
|
|
867
|
+
// (e.g. from an unsanitized-for-backticks authorName). Minimum 3, per
|
|
868
|
+
// CommonMark; longer only when `text` itself contains a run that long.
|
|
869
|
+
function _mdFenceLen(text) {
|
|
870
|
+
const runs = String(text == null ? '' : text).match(/`+/g) || [];
|
|
871
|
+
return Math.max(3, ...runs.map(r => r.length + 1));
|
|
872
|
+
}
|
|
873
|
+
|
|
735
874
|
export function toMarkdown(scan, meta={}){
|
|
736
875
|
const findings = normalizeFindings(scan);
|
|
737
876
|
const lines = ['# Agentic Security — Scan Report', ''];
|
|
@@ -764,6 +903,32 @@ export function toMarkdown(scan, meta={}){
|
|
|
764
903
|
lines.push(`| \`${f.file}:${f.line}\` | ${f.vuln} | ${f.cwe||'—'} | ${epss} | ${fix.replace(/\|/g,'\\|').slice(0,140)} |`);
|
|
765
904
|
}
|
|
766
905
|
}
|
|
906
|
+
// FR-PROV-018: one provenance block per finding that has one, reusing
|
|
907
|
+
// explainProvenance's content — never a second, divergent renderer.
|
|
908
|
+
const withProvenance = bySev[sev].filter(f => f.findingProvenance);
|
|
909
|
+
if (withProvenance.length) {
|
|
910
|
+
lines.push('');
|
|
911
|
+
lines.push('<details><summary>Provenance</summary>');
|
|
912
|
+
lines.push('');
|
|
913
|
+
for (const f of withProvenance) {
|
|
914
|
+
const block = explainProvenance(f);
|
|
915
|
+
if (!block) continue;
|
|
916
|
+
lines.push(`**\`${f.file}:${f.line}\`** — ${f.vuln}`);
|
|
917
|
+
// FR-PROV-026: sanitizeForTerminal (applied inside explainProvenance)
|
|
918
|
+
// strips control chars but NOT backticks, which are ordinary text —
|
|
919
|
+
// a malicious authorName containing ``` could otherwise break out of
|
|
920
|
+
// a fixed 3-backtick fence and inject raw Markdown/HTML into the
|
|
921
|
+
// report. Use a fence one backtick longer than any run already in
|
|
922
|
+
// the block (CommonMark's own escaping mechanism for fenced code),
|
|
923
|
+
// so normal content (never containing backticks) is unaffected.
|
|
924
|
+
const fence = '`'.repeat(_mdFenceLen(block));
|
|
925
|
+
lines.push(fence);
|
|
926
|
+
lines.push(block);
|
|
927
|
+
lines.push(fence);
|
|
928
|
+
lines.push('');
|
|
929
|
+
}
|
|
930
|
+
lines.push('</details>');
|
|
931
|
+
}
|
|
767
932
|
lines.push('');
|
|
768
933
|
}
|
|
769
934
|
return lines.join('\n');
|
|
@@ -841,6 +1006,16 @@ export function toSARIF(scan, meta={}){
|
|
|
841
1006
|
...(scan && scan._rulesetVersion ? { rulesetVersion: scan._rulesetVersion } : {}),
|
|
842
1007
|
...(scan && scan._rulesetVersionSource ? { rulesetVersionSource: scan._rulesetVersionSource } : {}),
|
|
843
1008
|
...(scan && scan._rulesetVersionMismatch ? { rulesetVersionMismatch: scan._rulesetVersionMismatch } : {}),
|
|
1009
|
+
// FR-PROV-018: run-level provenance summary — how many results
|
|
1010
|
+
// resolved which terminal status, so a SARIF consumer can judge
|
|
1011
|
+
// history coverage without walking every result's properties.
|
|
1012
|
+
...(findings.some(f => f.findingProvenance) ? {
|
|
1013
|
+
provenanceCoverage: findings.reduce((acc, f) => {
|
|
1014
|
+
const s = f.findingProvenance?.status || 'none';
|
|
1015
|
+
acc[s] = (acc[s] || 0) + 1;
|
|
1016
|
+
return acc;
|
|
1017
|
+
}, {}),
|
|
1018
|
+
} : {}),
|
|
844
1019
|
},
|
|
845
1020
|
}],
|
|
846
1021
|
results: findings.map(f => {
|
|
@@ -905,6 +1080,12 @@ export function toSARIF(scan, meta={}){
|
|
|
905
1080
|
signatureStatus: f.signatureStatus || (f._passThroughSigning ? 'pass-through' : (f._unsigned ? 'unsigned' : 'verified')),
|
|
906
1081
|
...(f._unsigned ? { unsigned: true } : {}),
|
|
907
1082
|
...(f._passThroughSigning ? { passThroughSigning: true } : {}),
|
|
1083
|
+
// FR-PROV-018: `f` is already normalized (findingProvenance already
|
|
1084
|
+
// passed through _normalizedProvenance/redactFindingProvenance by
|
|
1085
|
+
// normalizeFindings), so this is a redacted passthrough, never a
|
|
1086
|
+
// second redaction pass and never a read of a raw pre-normalization
|
|
1087
|
+
// finding.
|
|
1088
|
+
...(f.findingProvenance ? { findingProvenance: f.findingProvenance } : {}),
|
|
908
1089
|
},
|
|
909
1090
|
};}),
|
|
910
1091
|
}],
|
|
@@ -966,7 +1147,7 @@ export function toHTML(scan, meta = {}) {
|
|
|
966
1147
|
// server-side) so the browser render shows them without a second command.
|
|
967
1148
|
const findings = normalizeFindings(scan).map(f => {
|
|
968
1149
|
const ex = explainParts(f, { verbose: true });
|
|
969
|
-
return { ...f, _riskNote: riskNote(f), _explainWhy: ex.why, _explainHow: ex.how };
|
|
1150
|
+
return { ...f, _riskNote: riskNote(f), _explainWhy: ex.why, _explainHow: ex.how, _explainProvenance: explainProvenance(f) };
|
|
970
1151
|
});
|
|
971
1152
|
const counts = { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
|
|
972
1153
|
for (const f of findings) counts[f.severity] = (counts[f.severity] || 0) + 1;
|
|
@@ -1044,6 +1225,8 @@ export function toHTML(scan, meta = {}) {
|
|
|
1044
1225
|
.f-how{margin-top:6px;color:#94a3b8;font-size:13px}
|
|
1045
1226
|
.f-how code{font-family:ui-monospace,monospace;color:#e2e8f4}
|
|
1046
1227
|
.f-fix{background:#0d1f3d;border-left:3px solid #38bdf8;padding:8px 12px;margin-top:8px;border-radius:0 4px 4px 0}
|
|
1228
|
+
.f-provenance{background:#0f1f14;border-left:3px solid #34d058;padding:8px 12px;margin-top:8px;border-radius:0 4px 4px 0}
|
|
1229
|
+
.f-provenance pre{background:transparent;padding:0;margin:4px 0 0 0}
|
|
1047
1230
|
.hidden{display:none!important}
|
|
1048
1231
|
</style></head>
|
|
1049
1232
|
<body>
|
|
@@ -1099,6 +1282,7 @@ function makeCard(f) {
|
|
|
1099
1282
|
(f.snippet ? '<pre>' + esc(f.snippet) + '</pre>' : '') +
|
|
1100
1283
|
(f.masked ? '<pre style="color:#f97316">' + esc(f.masked) + ' (masked)</pre>' : '') +
|
|
1101
1284
|
(f.fix && f.fix.description ? '<div class="f-fix"><b>Fix:</b> ' + esc(f.fix.description) + (f.fix.code ? '<pre>' + esc(f.fix.code) + '</pre>' : '') + '</div>' : '') +
|
|
1285
|
+
(f._explainProvenance ? '<div class="f-provenance"><b>Provenance:</b><pre>' + esc(f._explainProvenance) + '</pre></div>' : '') +
|
|
1102
1286
|
'</div>';
|
|
1103
1287
|
div.addEventListener('click', () => div.classList.toggle('expanded'));
|
|
1104
1288
|
return div;
|
|
@@ -1178,7 +1362,10 @@ const RESET = '\x1b[0m';
|
|
|
1178
1362
|
const DIM = '\x1b[2m';
|
|
1179
1363
|
const BOLD = '\x1b[1m';
|
|
1180
1364
|
|
|
1181
|
-
|
|
1365
|
+
// `provenance` defaults to FALSE on purpose: the block is five-plus extra
|
|
1366
|
+
// lines per finding, and the default CLI listing is already dense. It prints
|
|
1367
|
+
// only when the operator asks for it.
|
|
1368
|
+
export function toCLI(scan, { verbose=false, color=true, provenance=false }={}){
|
|
1182
1369
|
const findings = normalizeFindings(scan);
|
|
1183
1370
|
const lines = [];
|
|
1184
1371
|
const c = (s, code) => color ? `${code}${s}${RESET}` : s;
|
|
@@ -1204,6 +1391,13 @@ export function toCLI(scan, { verbose=false, color=true }={}){
|
|
|
1204
1391
|
if (ex.how) lines.push(` ${c('how:', DIM)} ${ex.how}`);
|
|
1205
1392
|
if (ex.fix) lines.push(` ${c('fix:', DIM)} ${ex.fix}`);
|
|
1206
1393
|
if (ex.fixCode) for (const ln of ex.fixCode.split('\n').slice(0, 6)) lines.push(` ${c(ln, DIM)}`);
|
|
1394
|
+
if (provenance) {
|
|
1395
|
+
// `f` here is already normalized, so its findingProvenance has been
|
|
1396
|
+
// through redactFindingProvenance — the email is gone before it can
|
|
1397
|
+
// reach a terminal.
|
|
1398
|
+
const prov = explainProvenance(f);
|
|
1399
|
+
if (prov) for (const ln of prov.split('\n')) lines.push(` ${c(ln, DIM)}`);
|
|
1400
|
+
}
|
|
1207
1401
|
}
|
|
1208
1402
|
lines.push('');
|
|
1209
1403
|
const counts = { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
|
package/src/runScan.js
CHANGED
|
@@ -4,6 +4,7 @@ import * as fs from 'node:fs/promises';
|
|
|
4
4
|
import * as path from 'node:path';
|
|
5
5
|
import * as cp from 'node:child_process';
|
|
6
6
|
import { listFiles } from './util/glob.js';
|
|
7
|
+
import { hardenGitArgs, hardenGitEnv } from './util/git-hardening.js';
|
|
7
8
|
import { runFullScan, shouldScan, isKubernetesManifest, isCloudFormationTemplate, isInstructionFile } from './engine.js';
|
|
8
9
|
import { appendScanSnapshot } from './posture/security-trend.js';
|
|
9
10
|
import { recover as recoverFixHistory } from './posture/fix-history.js';
|
|
@@ -111,17 +112,28 @@ export async function readTree(root, { ignore = [] } = {}) {
|
|
|
111
112
|
|
|
112
113
|
// Feat-10: incremental scan via `--changed-since <git-ref>`. Returns the set of
|
|
113
114
|
// repo-relative paths modified since the ref, or null if git is unavailable.
|
|
115
|
+
//
|
|
116
|
+
// `root` is the scan target's repository, not this project's own trusted
|
|
117
|
+
// checkout — hardened per FR-PROV-024 / the second Finding Provenance PRD
|
|
118
|
+
// audit (same exposure class as provenance/git-evidence.js: a hostile
|
|
119
|
+
// .git/config's `core.fsmonitor` fires on the `git status --porcelain`
|
|
120
|
+
// call below just from reading repo state). `--no-ext-diff` is added to the
|
|
121
|
+
// `diff --name-only` call too: VERIFIED that shape does not itself invoke an
|
|
122
|
+
// external diff driver in current git (no content is rendered), but it costs
|
|
123
|
+
// nothing and keeps every `git diff` call site in this codebase uniformly
|
|
124
|
+
// hardened against the surface `material-change.js`'s `classifyGitDiff` was
|
|
125
|
+
// actually caught on (see that file's comment for the live exploit).
|
|
114
126
|
export function changedSince(root, gitRef) {
|
|
115
127
|
if (!gitRef) return null;
|
|
116
128
|
try {
|
|
117
|
-
const out = cp.execFileSync('git', ['diff', '--name-only', `${gitRef}...HEAD`], {
|
|
118
|
-
cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'],
|
|
129
|
+
const out = cp.execFileSync('git', hardenGitArgs(['diff', '--name-only', '--no-ext-diff', `${gitRef}...HEAD`]), {
|
|
130
|
+
cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], env: hardenGitEnv(),
|
|
119
131
|
});
|
|
120
132
|
const set = new Set(out.split('\n').filter(Boolean));
|
|
121
133
|
// Also include uncommitted changes
|
|
122
134
|
try {
|
|
123
|
-
const dirty = cp.execFileSync('git', ['status', '--porcelain'], {
|
|
124
|
-
cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'],
|
|
135
|
+
const dirty = cp.execFileSync('git', hardenGitArgs(['status', '--porcelain']), {
|
|
136
|
+
cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], env: hardenGitEnv(),
|
|
125
137
|
});
|
|
126
138
|
for (const line of dirty.split('\n')) {
|
|
127
139
|
const f = line.slice(3).trim();
|
|
@@ -151,9 +163,22 @@ export async function runScan(rootDir, opts = {}) {
|
|
|
151
163
|
// Caller may pre-build fileContents (used by the MCP server's scan_diff to
|
|
152
164
|
// scope a scan to a specific file list without walking the whole tree).
|
|
153
165
|
let fileContents, depFileContents;
|
|
166
|
+
// `completeScan` answers ONE question for the whole pipeline: does the file
|
|
167
|
+
// set below cover all of `root`, or only a subset of it? Anything downstream
|
|
168
|
+
// that reasons about the ABSENCE of a finding — most importantly the
|
|
169
|
+
// provenance lifecycle ledger, whose remediation pass closes every open
|
|
170
|
+
// stableId missing from this scan — is only sound on a complete scan. A
|
|
171
|
+
// subset scan that claims completeness marks the entire rest of the project
|
|
172
|
+
// remediated. It starts true and is only ever narrowed, so a new subsetting
|
|
173
|
+
// path added later must opt OUT explicitly rather than silently inherit a
|
|
174
|
+
// false claim of coverage.
|
|
175
|
+
let completeScan = true;
|
|
154
176
|
if (opts.fileContents) {
|
|
177
|
+
// Caller-supplied file list (MCP `scan_diff`, the LSP's on-save scan): by
|
|
178
|
+
// construction a subset of the tree, not a scan of it.
|
|
155
179
|
fileContents = opts.fileContents;
|
|
156
180
|
depFileContents = opts.depFileContents || {};
|
|
181
|
+
completeScan = false;
|
|
157
182
|
} else {
|
|
158
183
|
({ fileContents, depFileContents } = await readTree(root, opts));
|
|
159
184
|
}
|
|
@@ -167,6 +192,10 @@ export async function runScan(rootDir, opts = {}) {
|
|
|
167
192
|
if (changed.has(f)) filtered[f] = fileContents[f];
|
|
168
193
|
}
|
|
169
194
|
fileContents = filtered;
|
|
195
|
+
// Only when the filter actually applied. A `changedSince` that resolved
|
|
196
|
+
// to null (not a git repo / bad ref) is warned about below and scans the
|
|
197
|
+
// whole tree, which IS complete.
|
|
198
|
+
completeScan = false;
|
|
170
199
|
} else if (opts.onProgress) {
|
|
171
200
|
opts.onProgress({ phase: 'warning', file: 'changedSince ignored: not a git repo or invalid ref', current: 0, total: 0 });
|
|
172
201
|
}
|
|
@@ -174,7 +203,7 @@ export async function runScan(rootDir, opts = {}) {
|
|
|
174
203
|
|
|
175
204
|
// R8: `resume` is opt-in. Left undefined here, runFullScan falls back to the
|
|
176
205
|
// AGENTIC_SECURITY_RESUME=1 env var, which is off by default.
|
|
177
|
-
const scan = await runFullScan({ fileContents, depFileContents, scanRoot: root, resume: opts.resume, deep: opts.deep, deepInCi: opts.deepInCi }, opts.onProgress || (()=>{}));
|
|
206
|
+
const scan = await runFullScan({ fileContents, depFileContents, scanRoot: root, resume: opts.resume, deep: opts.deep, deepInCi: opts.deepInCi, completeScan }, opts.onProgress || (()=>{}));
|
|
178
207
|
// Premortem 2R4.2: stamp ruleset version + source on the scan result, and
|
|
179
208
|
// notify if the operator pinned a different version than what's installed.
|
|
180
209
|
try { stampScan(root, scan); } catch {}
|
package/src/sast/rate-limit.js
CHANGED
|
@@ -31,7 +31,13 @@ const AI_PATH_RE = /\/(?:ai|chat|generate|complete|completion|embed|embedding|gp
|
|
|
31
31
|
const PAYMENT_PATH_RE = /\/(?:pay(?:ment)?|checkout|stripe|order|subscribe|billing|invoice|charge|purchase)\b/i;
|
|
32
32
|
const CONTACT_PATH_RE = /\/(?:contact|submit|feedback|form|newsletter|subscribe|waitlist|signup|onboard)\b/i;
|
|
33
33
|
|
|
34
|
-
|
|
34
|
+
// Exported (FR-PROV-017) so posture/provenance/coordinator.js can reuse the
|
|
35
|
+
// EXACT SAME presence test as a `resolveMissingControl` predicate: "did a
|
|
36
|
+
// commit's historical blob have rate limiting" must be answered by the same
|
|
37
|
+
// logic that decides "does HEAD have rate limiting", or a drift between the
|
|
38
|
+
// two could fabricate a regression (or miss a real one) that only existed in
|
|
39
|
+
// the predicate's own disagreement with the detector, not in the code.
|
|
40
|
+
function hasRateLimit(content) {
|
|
35
41
|
return RL_IMPORT_RE.test(content) || RL_USAGE_RE.test(content) || REDIS_RL_RE.test(content);
|
|
36
42
|
}
|
|
37
43
|
|
|
@@ -77,7 +83,7 @@ const CATEGORY_META = {
|
|
|
77
83
|
function scanRateLimit(file, content) {
|
|
78
84
|
if (!_SCAN_EXT_RE.test(file)) return [];
|
|
79
85
|
if (_NONPROD_RE.test(file)) return [];
|
|
80
|
-
if (
|
|
86
|
+
if (hasRateLimit(content)) return [];
|
|
81
87
|
const findings = [];
|
|
82
88
|
const lines = content.split('\n');
|
|
83
89
|
|
|
@@ -99,6 +105,18 @@ function scanRateLimit(file, content) {
|
|
|
99
105
|
description: meta.description,
|
|
100
106
|
remediation: meta.remediation,
|
|
101
107
|
cwe: meta.cwe,
|
|
108
|
+
// FR-PROV-017: routes posture/provenance/coordinator.js to
|
|
109
|
+
// resolveMissingControl instead of the plain SAST origin-resolver.
|
|
110
|
+
// "A route lacks rate limiting" is exactly the "was this control
|
|
111
|
+
// ever present, and if so when did it disappear" question that
|
|
112
|
+
// resolver answers — a plain SAST resolver would instead ask "when
|
|
113
|
+
// was this LINE introduced," which is the wrong question for a
|
|
114
|
+
// finding about something ABSENT. An explicit boolean marker set
|
|
115
|
+
// here (rather than coordinator.js string-matching finding.id/vuln)
|
|
116
|
+
// keeps the two modules' string formats decoupled, matching how
|
|
117
|
+
// isDirect/isTransitiveSca-style markers already route elsewhere in
|
|
118
|
+
// this pipeline.
|
|
119
|
+
missingControlCandidate: true,
|
|
102
120
|
});
|
|
103
121
|
}
|
|
104
122
|
}
|
|
@@ -120,6 +138,18 @@ function scanRateLimit(file, content) {
|
|
|
120
138
|
description: meta.description,
|
|
121
139
|
remediation: meta.remediation,
|
|
122
140
|
cwe: meta.cwe,
|
|
141
|
+
// FR-PROV-017: routes posture/provenance/coordinator.js to
|
|
142
|
+
// resolveMissingControl instead of the plain SAST origin-resolver.
|
|
143
|
+
// "A route lacks rate limiting" is exactly the "was this control
|
|
144
|
+
// ever present, and if so when did it disappear" question that
|
|
145
|
+
// resolver answers — a plain SAST resolver would instead ask "when
|
|
146
|
+
// was this LINE introduced," which is the wrong question for a
|
|
147
|
+
// finding about something ABSENT. An explicit boolean marker set
|
|
148
|
+
// here (rather than coordinator.js string-matching finding.id/vuln)
|
|
149
|
+
// keeps the two modules' string formats decoupled, matching how
|
|
150
|
+
// isDirect/isTransitiveSca-style markers already route elsewhere in
|
|
151
|
+
// this pipeline.
|
|
152
|
+
missingControlCandidate: true,
|
|
123
153
|
});
|
|
124
154
|
}
|
|
125
155
|
}
|
|
@@ -127,4 +157,4 @@ function scanRateLimit(file, content) {
|
|
|
127
157
|
return findings;
|
|
128
158
|
}
|
|
129
159
|
|
|
130
|
-
export { scanRateLimit };
|
|
160
|
+
export { scanRateLimit, hasRateLimit };
|
|
@@ -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
|
+
}
|