@clear-capabilities/agentic-security-scanner 0.143.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 +555 -0
- package/bin/agentic-security.js +770 -49
- package/dist/1.index.js +223 -0
- package/dist/113.index.js +117 -18
- package/dist/144.index.js +163 -0
- package/dist/178.index.js +24 -6
- package/dist/238.index.js +3 -2
- package/dist/265.index.js +191 -0
- package/dist/271.index.js +165 -0
- package/dist/384.index.js +1 -1
- package/dist/435.index.js +187 -52
- package/dist/444.index.js +11 -2
- package/dist/449.index.js +76 -12
- package/dist/526.index.js +117 -18
- package/dist/552.index.js +97 -0
- package/dist/637.index.js +27 -5
- package/dist/730.index.js +311 -0
- package/dist/736.index.js +301 -0
- package/dist/824.index.js +7 -0
- package/dist/905.index.js +88 -22
- package/dist/920.index.js +491 -0
- package/dist/970.index.js +173 -0
- package/dist/agentic-security.mjs +13 -13
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/dist/calibration-seed.json +2 -0
- package/package.json +23 -9
- package/src/compare.js +6 -1
- package/src/dataflow/CLAUDE.md +1 -1
- package/src/dataflow/index.js +18 -0
- package/src/dataflow/privacy-catalog.js +290 -0
- package/src/dataflow/privacy-deep-walker.js +515 -0
- package/src/dataflow/privacy-governance.js +126 -0
- package/src/dataflow/privacy-inventory.js +154 -0
- package/src/dataflow/privacy-sink-policy.js +125 -0
- package/src/dataflow/privacy-taint.js +115 -54
- package/src/dataflow/privacy-taxonomy.js +233 -0
- package/src/discovery/disprove.js +7 -3
- package/src/discovery/hunter.js +9 -5
- package/src/discovery/index.js +2 -2
- package/src/discovery/llm-invoke.js +69 -13
- package/src/egress/audit.js +147 -0
- package/src/egress/policy.js +313 -0
- package/src/egress/redact.js +180 -0
- package/src/engine.js +1048 -302
- package/src/fix/apply-fix-service.js +404 -0
- package/src/fix/approver-registry.js +157 -0
- package/src/history-scan.js +22 -5
- package/src/ir/CLAUDE.md +1 -1
- package/src/llm-validator/index.js +86 -9
- package/src/llm-validator/model-status.js +66 -0
- package/src/lsp/server.js +49 -2
- package/src/mcp/tools.js +177 -50
- package/src/pipeline/analyzer-supervisor.js +93 -0
- package/src/pipeline/analyzer-worker.js +26 -0
- package/src/pipeline/annotator-runner.js +33 -0
- package/src/pipeline/assurance-mode.js +154 -0
- package/src/pipeline/cascade-worker-pool.js +172 -0
- package/src/pipeline/cascade-worker.js +43 -0
- package/src/pipeline/coverage-ledger.js +0 -0
- package/src/pipeline/detector-runner.js +51 -0
- package/src/pipeline/enrichment-completion.js +58 -0
- package/src/pipeline/evidence-provenance.js +91 -0
- package/src/pipeline/finding-schema.js +108 -0
- package/src/pipeline/legacy-compat.js +101 -0
- package/src/pipeline/producer-collector.js +48 -0
- package/src/pipeline/producer-registry.js +112 -0
- package/src/pipeline/scan-health.js +144 -0
- package/src/posture/CLAUDE.md +123 -0
- package/src/posture/accuracy-scorecard.js +156 -1
- package/src/posture/adversary-agent.js +15 -3
- package/src/posture/artifact-registry.js +241 -0
- package/src/posture/auditor-walkthrough.js +186 -21
- package/src/posture/calibration-feedback.js +201 -0
- package/src/posture/calibration-seed.json +2 -0
- package/src/posture/calibration.js +25 -0
- package/src/posture/compliance-evidence-signing.js +131 -0
- package/src/posture/compliance-policy.js +324 -17
- package/src/posture/cross-repo-memory.js +7 -2
- package/src/posture/custom-rules.js +36 -0
- package/src/posture/deterministic.js +8 -1
- package/src/posture/encryption-provider.js +205 -0
- package/src/posture/evidence-grade-wording.js +71 -0
- package/src/posture/fix-history.js +137 -20
- package/src/posture/fix-honesty-gate.js +47 -6
- package/src/posture/fix-verify.js +65 -8
- package/src/posture/fleet.js +0 -0
- package/src/posture/flow-narration.js +7 -2
- package/src/posture/git-history.js +13 -5
- package/src/posture/legal-hold.js +140 -0
- package/src/posture/llm-redteam.js +10 -1
- package/src/posture/material-change.js +111 -2
- package/src/posture/mttr.js +75 -12
- package/src/posture/policy-bundle.js +274 -0
- package/src/posture/pre-incident-archaeology.js +39 -7
- package/src/posture/privacy-framework.js +47 -6
- package/src/posture/production-feedback.js +179 -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/retention-policy.js +132 -0
- package/src/posture/risk-dollars.js +216 -26
- package/src/posture/sbom-diff.js +15 -2
- package/src/posture/scan-checkpoint.js +176 -31
- package/src/posture/secret-history.js +10 -2
- package/src/posture/state-dir.js +64 -5
- package/src/posture/state-lifecycle-report.js +77 -0
- package/src/posture/suppressions.js +59 -3
- package/src/posture/vuln-archaeology.js +8 -2
- package/src/pr-delta.js +25 -4
- package/src/privacy/ir-adapter.js +380 -0
- package/src/report/index.js +248 -5
- package/src/report/oscal.js +7 -2
- package/src/runScan.js +34 -5
- package/src/sast/cpp.js +3 -14
- package/src/sast/rate-limit.js +33 -3
- package/src/sca/llm-function-extract.js +6 -0
- package/src/util/git-hardening.js +128 -0
package/src/report/index.js
CHANGED
|
@@ -4,6 +4,20 @@ import { _isCustomSuppressed } from '../engine.js';
|
|
|
4
4
|
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
|
+
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 };
|
|
7
21
|
|
|
8
22
|
const SEV_RANK = { critical: 0, high: 1, medium: 2, low: 3, info: 4 };
|
|
9
23
|
const SEV_TO_SARIF = { critical: 'error', high: 'error', medium: 'warning', low: 'note', info: 'none' };
|
|
@@ -83,6 +97,62 @@ function explainParts(f, { verbose = false } = {}) {
|
|
|
83
97
|
return { why, how, fix: fix.replace(/\s+/g, ' ').trim(), fixCode };
|
|
84
98
|
}
|
|
85
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
|
+
|
|
86
156
|
function fingerprint(f){
|
|
87
157
|
const s = `${f.file}:${f.line||f.source?.line||0}:${f.vuln||f.type||''}`;
|
|
88
158
|
return crypto.createHash('sha256').update(s).digest('hex').slice(0, 16);
|
|
@@ -107,6 +177,24 @@ export function _remediationOf(f) {
|
|
|
107
177
|
return null;
|
|
108
178
|
}
|
|
109
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
|
+
|
|
110
198
|
export function normalizeFindings(scan){
|
|
111
199
|
const out = [];
|
|
112
200
|
// Feat-4: filter findings via custom suppressions, recording the suppression
|
|
@@ -249,6 +337,10 @@ export function normalizeFindings(scan){
|
|
|
249
337
|
cloneClusterSize: typeof f.cloneClusterSize === 'number' ? f.cloneClusterSize : null,
|
|
250
338
|
provenance: f.provenance || null,
|
|
251
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),
|
|
252
344
|
typeNarrowed: f.typeNarrowed || null,
|
|
253
345
|
strideCategory: f.strideCategory || null,
|
|
254
346
|
personaScores: f.personaScores || null,
|
|
@@ -348,6 +440,15 @@ export function normalizeFindings(scan){
|
|
|
348
440
|
commit: s.commit || null,
|
|
349
441
|
historical: s._historical === true,
|
|
350
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),
|
|
351
452
|
});
|
|
352
453
|
}
|
|
353
454
|
for (const lv of (scan.logicVulns||[])) {
|
|
@@ -375,6 +476,15 @@ export function normalizeFindings(scan){
|
|
|
375
476
|
ecosystem: lv.ecosystem || null,
|
|
376
477
|
license: lv.license || null,
|
|
377
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),
|
|
378
488
|
});
|
|
379
489
|
}
|
|
380
490
|
for (const sc of (scan.supplyChain||[])) {
|
|
@@ -424,8 +534,28 @@ export function normalizeFindings(scan){
|
|
|
424
534
|
toxicity: sc.toxicityScore ?? null,
|
|
425
535
|
toxicityFactors: sc.toxicityFactors || null,
|
|
426
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),
|
|
427
551
|
});
|
|
428
552
|
}
|
|
553
|
+
// FR-108: backfill deprecated field names from their current replacements
|
|
554
|
+
// before this shape reaches ANY consumer (JSON/SARIF/HTML/CSV/JUnit/MCP
|
|
555
|
+
// all derive from this one function) — one place, covers every output
|
|
556
|
+
// format, matches this function's own role as "the canonical shape".
|
|
557
|
+
for (const f of out) applyLegacyCompat(f);
|
|
558
|
+
|
|
429
559
|
// Sort by severity tier, then within a tier by EPSS percentile (desc) so that
|
|
430
560
|
// CVEs with active in-the-wild abuse float above theoretical CVEs.
|
|
431
561
|
return out.sort((a, b) => {
|
|
@@ -516,6 +646,13 @@ export function toJSON(scan, meta={}, opts={}){
|
|
|
516
646
|
// threw and were skipped. The findings still ship; downstream consumers
|
|
517
647
|
// see the gap.
|
|
518
648
|
annotatorErrors: Array.isArray(scan.annotatorErrors) ? scan.annotatorErrors : [],
|
|
649
|
+
// FR-201 (assurance-hardening PRD, E2): per-file SAST/logic/secrets
|
|
650
|
+
// detector errors, captured by pipeline/detector-runner.js's runDetector()
|
|
651
|
+
// isolation wrapper. Empty array means clean; entries here mean one or
|
|
652
|
+
// more detectors threw on a specific file and were skipped -- other
|
|
653
|
+
// detectors and other files still ran (that isolation is the acceptance
|
|
654
|
+
// criterion itself, not just this field's presence).
|
|
655
|
+
detectorErrors: Array.isArray(scan.detectorErrors) ? scan.detectorErrors : [],
|
|
519
656
|
// R4 — run attestation: a stable, order-independent digest over this
|
|
520
657
|
// finding set bound to the engine/ruleset/bundle that produced it.
|
|
521
658
|
// Attached by the CLI (posture/attestation.js); null when not computed.
|
|
@@ -529,6 +666,10 @@ export function toJSON(scan, meta={}, opts={}){
|
|
|
529
666
|
// render an empty one. An AUTHORISED suppression is reported too — the
|
|
530
667
|
// signature proves who asked for it, not that the results are absent.
|
|
531
668
|
suppressedRules: scan.suppressedRules || null,
|
|
669
|
+
// FR-108: null in the (eventual, intended) steady state where no finding
|
|
670
|
+
// used a deprecated field name; present only when normalizeFindings()
|
|
671
|
+
// actually backfilled a legacy alias, naming exactly which and why.
|
|
672
|
+
legacyFieldNotice: legacyFieldDeprecationNotice(findings),
|
|
532
673
|
_scanMeta: scan._scanMeta || null,
|
|
533
674
|
// S7: engine.js computes these on every scan with components, and their
|
|
534
675
|
// own findings already flow into `findings` above — but the structured
|
|
@@ -546,6 +687,21 @@ export function toJSON(scan, meta={}, opts={}){
|
|
|
546
687
|
rootCauseSweep: scan.rootCauseSweep || null,
|
|
547
688
|
attackTaxonomy: scan.attackTaxonomy || null,
|
|
548
689
|
privacyFramework: scan.privacyFramework || null,
|
|
690
|
+
// FR-405: `false` is a real, meaningful value here (privacy analysis
|
|
691
|
+
// ran but was not IR-backed) — must not collapse to null via `||`,
|
|
692
|
+
// which would make it indistinguishable from "never ran at all".
|
|
693
|
+
privacyIrBacked: scan.privacyIrBacked ?? null,
|
|
694
|
+
// FR-402: which privacy data-classification taxonomy version actually
|
|
695
|
+
// classified this scan (built-in, or built-in+custom when an operator's
|
|
696
|
+
// .agentic-security/privacy-taxonomy.json extended it) — see
|
|
697
|
+
// dataflow/privacy-taxonomy.js. Null means the annotator never ran.
|
|
698
|
+
privacyTaxonomyVersion: scan.privacyTaxonomyVersion ?? null,
|
|
699
|
+
// Assurance-hardening PRD FR-206 (Milestone 0): additive scan-health
|
|
700
|
+
// summary (pipeline/scan-health.js) — separates "no findings" from
|
|
701
|
+
// "analysis complete". Null when the engine did not compute one (e.g. a
|
|
702
|
+
// hand-built `scan` object in a test), same convention as the S7 fields
|
|
703
|
+
// above; a real scan always computes it.
|
|
704
|
+
scanHealth: scan.scanHealth || null,
|
|
549
705
|
};
|
|
550
706
|
if (opts.includeSuppressed) out.suppressed = scan.suppressions||[];
|
|
551
707
|
return out;
|
|
@@ -643,15 +799,22 @@ export function toCSV(scan){
|
|
|
643
799
|
const s = String(v);
|
|
644
800
|
return /[",\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s;
|
|
645
801
|
};
|
|
646
|
-
|
|
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'];
|
|
647
807
|
const rows = [header.join(',')];
|
|
648
808
|
for (const f of findings) {
|
|
809
|
+
const fp = f.findingProvenance;
|
|
649
810
|
rows.push([
|
|
650
811
|
esc(f.id), esc(f.severity), esc(f.vuln), esc(f.cwe), esc(f.cvss || ''),
|
|
651
812
|
esc(f.owasp || ''), esc(f.file), esc(f.line),
|
|
652
813
|
esc(f.confidence == null ? '' : f.confidence.toFixed(3)),
|
|
653
814
|
esc(f.reachable == null ? '' : f.reachable),
|
|
654
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 || ''),
|
|
655
818
|
].join(','));
|
|
656
819
|
}
|
|
657
820
|
return rows.join('\n');
|
|
@@ -699,6 +862,15 @@ export function toJUnit(scan, meta={}){
|
|
|
699
862
|
return lines.join('\n');
|
|
700
863
|
}
|
|
701
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
|
+
|
|
702
874
|
export function toMarkdown(scan, meta={}){
|
|
703
875
|
const findings = normalizeFindings(scan);
|
|
704
876
|
const lines = ['# Agentic Security — Scan Report', ''];
|
|
@@ -731,6 +903,32 @@ export function toMarkdown(scan, meta={}){
|
|
|
731
903
|
lines.push(`| \`${f.file}:${f.line}\` | ${f.vuln} | ${f.cwe||'—'} | ${epss} | ${fix.replace(/\|/g,'\\|').slice(0,140)} |`);
|
|
732
904
|
}
|
|
733
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
|
+
}
|
|
734
932
|
lines.push('');
|
|
735
933
|
}
|
|
736
934
|
return lines.join('\n');
|
|
@@ -808,6 +1006,16 @@ export function toSARIF(scan, meta={}){
|
|
|
808
1006
|
...(scan && scan._rulesetVersion ? { rulesetVersion: scan._rulesetVersion } : {}),
|
|
809
1007
|
...(scan && scan._rulesetVersionSource ? { rulesetVersionSource: scan._rulesetVersionSource } : {}),
|
|
810
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
|
+
} : {}),
|
|
811
1019
|
},
|
|
812
1020
|
}],
|
|
813
1021
|
results: findings.map(f => {
|
|
@@ -872,6 +1080,12 @@ export function toSARIF(scan, meta={}){
|
|
|
872
1080
|
signatureStatus: f.signatureStatus || (f._passThroughSigning ? 'pass-through' : (f._unsigned ? 'unsigned' : 'verified')),
|
|
873
1081
|
...(f._unsigned ? { unsigned: true } : {}),
|
|
874
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 } : {}),
|
|
875
1089
|
},
|
|
876
1090
|
};}),
|
|
877
1091
|
}],
|
|
@@ -933,7 +1147,7 @@ export function toHTML(scan, meta = {}) {
|
|
|
933
1147
|
// server-side) so the browser render shows them without a second command.
|
|
934
1148
|
const findings = normalizeFindings(scan).map(f => {
|
|
935
1149
|
const ex = explainParts(f, { verbose: true });
|
|
936
|
-
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) };
|
|
937
1151
|
});
|
|
938
1152
|
const counts = { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
|
|
939
1153
|
for (const f of findings) counts[f.severity] = (counts[f.severity] || 0) + 1;
|
|
@@ -1011,6 +1225,8 @@ export function toHTML(scan, meta = {}) {
|
|
|
1011
1225
|
.f-how{margin-top:6px;color:#94a3b8;font-size:13px}
|
|
1012
1226
|
.f-how code{font-family:ui-monospace,monospace;color:#e2e8f4}
|
|
1013
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}
|
|
1014
1230
|
.hidden{display:none!important}
|
|
1015
1231
|
</style></head>
|
|
1016
1232
|
<body>
|
|
@@ -1066,6 +1282,7 @@ function makeCard(f) {
|
|
|
1066
1282
|
(f.snippet ? '<pre>' + esc(f.snippet) + '</pre>' : '') +
|
|
1067
1283
|
(f.masked ? '<pre style="color:#f97316">' + esc(f.masked) + ' (masked)</pre>' : '') +
|
|
1068
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>' : '') +
|
|
1069
1286
|
'</div>';
|
|
1070
1287
|
div.addEventListener('click', () => div.classList.toggle('expanded'));
|
|
1071
1288
|
return div;
|
|
@@ -1145,7 +1362,10 @@ const RESET = '\x1b[0m';
|
|
|
1145
1362
|
const DIM = '\x1b[2m';
|
|
1146
1363
|
const BOLD = '\x1b[1m';
|
|
1147
1364
|
|
|
1148
|
-
|
|
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 }={}){
|
|
1149
1369
|
const findings = normalizeFindings(scan);
|
|
1150
1370
|
const lines = [];
|
|
1151
1371
|
const c = (s, code) => color ? `${code}${s}${RESET}` : s;
|
|
@@ -1171,6 +1391,13 @@ export function toCLI(scan, { verbose=false, color=true }={}){
|
|
|
1171
1391
|
if (ex.how) lines.push(` ${c('how:', DIM)} ${ex.how}`);
|
|
1172
1392
|
if (ex.fix) lines.push(` ${c('fix:', DIM)} ${ex.fix}`);
|
|
1173
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
|
+
}
|
|
1174
1401
|
}
|
|
1175
1402
|
lines.push('');
|
|
1176
1403
|
const counts = { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
|
|
@@ -1284,17 +1511,33 @@ export function toShipVerdict(scan, options = {}) {
|
|
|
1284
1511
|
const confirmedCount = findings.filter(f => f.validated === true || f.confirmed === true).length;
|
|
1285
1512
|
const cats = categoryScores(findings);
|
|
1286
1513
|
|
|
1514
|
+
// FR-206: "a partial zero-finding scan never says clean." `scanHealth`
|
|
1515
|
+
// (computeScanHealth, scan-health.js) was already computed correctly —
|
|
1516
|
+
// an annotator error, a file timeout, or a skipped analyzer already
|
|
1517
|
+
// demoted `status` to 'partial' — but nothing in this, the actual
|
|
1518
|
+
// human-facing one-screen verdict, ever consulted it. Zero actionable
|
|
1519
|
+
// findings on a scan that didn't finish cleanly is "we don't know," not
|
|
1520
|
+
// "safe."
|
|
1521
|
+
const scanIncomplete = scan.scanHealth && scan.scanHealth.status && scan.scanHealth.status !== 'complete';
|
|
1522
|
+
|
|
1287
1523
|
const lines = [];
|
|
1288
1524
|
const bar = '─────────────────────────────────────────';
|
|
1289
1525
|
// Patch the mascot reacts to the result — APPROVE if clean, ALERT if findings.
|
|
1290
|
-
|
|
1526
|
+
const clean = actionable.length === 0 && !scanIncomplete;
|
|
1527
|
+
lines.push(clean ? approveFace({ color }) : alertFace({ color }));
|
|
1291
1528
|
lines.push(bar);
|
|
1292
|
-
if (
|
|
1529
|
+
if (clean) {
|
|
1293
1530
|
lines.push(c(' ✅ Safe to deploy', SEV_COLOR.low + BOLD));
|
|
1531
|
+
} else if (actionable.length === 0) {
|
|
1532
|
+
lines.push(c(' ⚠️ Scan incomplete — cannot confirm safe to deploy', SEV_COLOR.high + BOLD));
|
|
1294
1533
|
} else {
|
|
1295
1534
|
lines.push(c(' ❌ Not safe to deploy', SEV_COLOR.critical + BOLD));
|
|
1296
1535
|
}
|
|
1297
1536
|
lines.push(bar);
|
|
1537
|
+
if (scanIncomplete) {
|
|
1538
|
+
const conditions = Array.isArray(scan.scanHealth.conditions) ? scan.scanHealth.conditions : [];
|
|
1539
|
+
lines.push(c(` ${scan.scanHealth.status}: ${conditions[0] || 'analysis did not complete cleanly'}${conditions.length > 1 ? ` (+${conditions.length - 1} more)` : ''}`, SEV_COLOR.high));
|
|
1540
|
+
}
|
|
1298
1541
|
lines.push(` • ${sev.critical} critical · ${sev.high} high · ${advisoryCount} advisory`);
|
|
1299
1542
|
// Per-category 0..100 score bars — Secrets / Permissions / Hooks / MCP / Agents.
|
|
1300
1543
|
// Only render when at least one category has been contributed to (skip the
|
package/src/report/oscal.js
CHANGED
|
@@ -50,6 +50,7 @@
|
|
|
50
50
|
import crypto from 'node:crypto';
|
|
51
51
|
import { isDeterministic, SCANNER_VERSION } from '../posture/deterministic.js';
|
|
52
52
|
import { normalizeFindings, TOOL_CAVEATS } from './index.js';
|
|
53
|
+
import { EVIDENCE_GRADE_DISCLAIMER } from '../posture/evidence-grade-wording.js';
|
|
53
54
|
|
|
54
55
|
// The OSCAL release these documents declare conformance to. Bump deliberately:
|
|
55
56
|
// `oscal-version` is a claim a validator checks the rest of the document
|
|
@@ -620,9 +621,13 @@ export function toOSCALCompliance(framework, rows, meta = {}) {
|
|
|
620
621
|
observations,
|
|
621
622
|
...(findings.length ? { findings } : {}),
|
|
622
623
|
...(risks.length ? { risks } : {}),
|
|
624
|
+
// FR-507: names all three assurance tiers explicitly, not just "not
|
|
625
|
+
// certified" — see evidence-grade-wording.js for why that distinction
|
|
626
|
+
// matters (a reader who has never heard "attestation" used correctly
|
|
627
|
+
// has no way to know a real one is a different, valid artifact they
|
|
628
|
+
// might separately need).
|
|
623
629
|
remarks:
|
|
624
|
-
|
|
625
|
-
+ 'of compliance and no licensed assessor reviewed it. '
|
|
630
|
+
`${EVIDENCE_GRADE_DISCLAIMER} No licensed assessor reviewed this document. `
|
|
626
631
|
+ TOOL_CAVEATS.map(c => `${c.id}: ${c.shortDescription}`).join(' | '),
|
|
627
632
|
}],
|
|
628
633
|
'back-matter': { resources: [frameworkResource, ...caveats] },
|
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/cpp.js
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
// - hardcoded hardcoded user/password in fopen / connect calls
|
|
17
17
|
|
|
18
18
|
import { blankComments } from './_comment-strip.js';
|
|
19
|
+
import { classifySecretCandidate as _entropyClassifySecret } from './_secret-entropy.js';
|
|
19
20
|
|
|
20
21
|
// ── context detectors ───────────────────────────────────────────────────────
|
|
21
22
|
|
|
@@ -279,14 +280,8 @@ const FINDINGS = [
|
|
|
279
280
|
const val = m && m[2];
|
|
280
281
|
if (!val) return false;
|
|
281
282
|
try {
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
// eslint-disable-next-line no-unused-vars
|
|
285
|
-
const { classifySecretCandidate } = _entropyMod || {};
|
|
286
|
-
if (classifySecretCandidate) {
|
|
287
|
-
const r = classifySecretCandidate(val);
|
|
288
|
-
if (r.skip) return false;
|
|
289
|
-
}
|
|
283
|
+
const r = _entropyClassifySecret(val);
|
|
284
|
+
if (r.skip) return false;
|
|
290
285
|
} catch { /* fail open */ }
|
|
291
286
|
return true;
|
|
292
287
|
},
|
|
@@ -319,12 +314,6 @@ const FINDINGS = [
|
|
|
319
314
|
},
|
|
320
315
|
];
|
|
321
316
|
|
|
322
|
-
// Late-bound entropy module — imported via a dynamic require shim so the
|
|
323
|
-
// rule table can lazy-call it from inside gate functions without creating
|
|
324
|
-
// a circular import at module load time.
|
|
325
|
-
let _entropyMod = null;
|
|
326
|
-
import('./_secret-entropy.js').then(m => { _entropyMod = m; }).catch(() => { _entropyMod = null; });
|
|
327
|
-
|
|
328
317
|
function lineOf(raw, idx) { return raw.substring(0, idx).split('\n').length; }
|
|
329
318
|
|
|
330
319
|
export function scanCpp(fp, raw) {
|
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 };
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import * as crypto from 'node:crypto';
|
|
10
10
|
import * as fs from 'node:fs';
|
|
11
11
|
import * as path from 'node:path';
|
|
12
|
+
import { evaluateEgress } from '../egress/policy.js';
|
|
12
13
|
|
|
13
14
|
const CACHE_DIR = process.env.XDG_CONFIG_HOME
|
|
14
15
|
? path.join(process.env.XDG_CONFIG_HOME, 'agentic-security', 'llm-sca-cache')
|
|
@@ -63,6 +64,11 @@ export async function extractVulnFunctionsViaLLM(supplyChain, opts = {}) {
|
|
|
63
64
|
if (!isLlmScaEnabled()) return [];
|
|
64
65
|
const config = _endpointConfig();
|
|
65
66
|
if (!config) return [];
|
|
67
|
+
// FR-601: one decision for the whole batch (same endpoint for every CVE in
|
|
68
|
+
// this run) — evaluated before any of the per-candidate prompts below are
|
|
69
|
+
// built.
|
|
70
|
+
const egressDecision = evaluateEgress({ scanRoot: opts.scanRoot, purpose: 'sca-llm-function-extract', endpoint: config.endpoint });
|
|
71
|
+
if (!egressDecision.allowed) return [];
|
|
66
72
|
|
|
67
73
|
const enriched = [];
|
|
68
74
|
const candidates = (supplyChain || []).filter(sc =>
|