@clear-capabilities/agentic-security-scanner 0.136.2 → 0.137.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 +880 -0
- package/bin/agentic-security.js +189 -37
- package/dist/113.index.js +13 -4
- package/dist/178.index.js +1 -1
- package/dist/207.index.js +5 -4
- package/dist/238.index.js +1 -1
- package/dist/317.index.js +36 -6
- package/dist/384.index.js +1 -1
- package/dist/435.index.js +192 -15
- package/dist/444.index.js +20 -11
- package/dist/449.index.js +8 -1
- package/dist/526.index.js +3 -3
- package/dist/637.index.js +1 -1
- package/dist/agentic-security.mjs +15 -15
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/dist/compliance-frameworks/nist-privacy-1-1.json +2 -2
- package/dist/compliance-frameworks/owasp-asvs-5.json +1 -1
- package/package.json +21 -13
- package/src/dataflow/CLAUDE.md +12 -4
- package/src/dataflow/builtin-summaries.js +1 -1
- package/src/dataflow/catalog-expanded.js +1 -0
- package/src/dataflow/catalog.js +157 -31
- package/src/dataflow/engine.js +639 -112
- package/src/dataflow/implicit-flow.js +68 -36
- package/src/dataflow/incremental.js +18 -3
- package/src/dataflow/index.js +17 -1
- package/src/dataflow/points-to.js +19 -6
- package/src/dataflow/proven-clean.js +41 -0
- package/src/dataflow/sanitizer-gate.js +35 -9
- package/src/dataflow/sanitizer-proof.js +21 -3
- package/src/dataflow/stub-aware-filter.js +36 -13
- package/src/dataflow/summaries.js +21 -2
- package/src/engine.js +430 -196
- package/src/ir/CLAUDE.md +16 -2
- package/src/ir/balanced-call.js +55 -0
- package/src/ir/class-hierarchy.js +57 -11
- package/src/ir/index.js +14 -2
- package/src/ir/parser-cs.js +513 -40
- package/src/ir/parser-go.js +29 -11
- package/src/ir/parser-java.js +300 -20
- package/src/ir/parser-js.js +300 -22
- package/src/ir/parser-kt.js +436 -18
- package/src/ir/parser-php.js +631 -38
- package/src/ir/parser-py.helper.py +32 -2
- package/src/ir/parser-py.js +31 -4
- package/src/ir/parser-rb.js +161 -26
- package/src/ir/ssa.js +6 -1
- package/src/lsp/server.js +35 -3
- package/src/mcp/CLAUDE.md +9 -2
- package/src/mcp/redact.js +26 -0
- package/src/mcp/tools.js +164 -15
- package/src/posture/CLAUDE.md +19 -7
- package/src/posture/accuracy-scorecard.js +9 -1
- package/src/posture/aibom.js +12 -8
- package/src/posture/auditor-walkthrough.js +102 -3
- package/src/posture/autopilot.js +8 -1
- package/src/posture/calibration-drift.js +11 -5
- package/src/posture/calibration.js +24 -2
- package/src/posture/clustering.js +12 -1
- package/src/posture/compliance-frameworks/nist-privacy-1-1.json +2 -2
- package/src/posture/compliance-frameworks/owasp-asvs-5.json +1 -1
- package/src/posture/compliance-policy.js +33 -1
- package/src/posture/confidence.js +44 -10
- package/src/posture/corpus-enroll.js +9 -5
- package/src/posture/corpus-match.js +19 -0
- package/src/posture/csharp-analysis.js +62 -3
- package/src/posture/deploy-platform.js +4 -1
- package/src/posture/drift.js +7 -1
- package/src/posture/epss.js +13 -1
- package/src/posture/evidence-bundle.js +36 -6
- package/src/posture/exploitability-probability.js +13 -1
- package/src/posture/falsification.js +23 -2
- package/src/posture/fix-metrics.js +1 -1
- package/src/posture/fix-verify-loop.js +10 -1
- package/src/posture/iac-reachability.js +14 -8
- package/src/posture/integrity.js +25 -7
- package/src/posture/model-rescan.js +65 -0
- package/src/posture/mttr.js +5 -0
- package/src/posture/poc-inprocess.js +27 -8
- package/src/posture/regression-test-gen.js +23 -8
- package/src/posture/reverse-blast-radius.js +5 -1
- package/src/posture/risk-dollars.js +18 -1
- package/src/posture/sbom.js +2 -2
- package/src/posture/secret-history.js +20 -11
- package/src/posture/security-trend.js +7 -1
- package/src/posture/stack-playbook.js +22 -1
- package/src/posture/threat-model-grounding.js +2 -2
- package/src/posture/validator-metrics.js +10 -3
- package/src/posture/verifier.js +32 -57
- package/src/report/index.js +183 -14
- package/src/runScan.js +1 -1
- package/src/sast/_comment-strip.js +15 -4
- package/src/sast/_secret-entropy.js +1 -1
- package/src/sast/authz.js +6 -4
- package/src/sast/bench-shape/index.js +2 -7
- package/src/sast/claude-md-prompt-injection.js +14 -3
- package/src/sast/cloud-iam.js +60 -7
- package/src/sast/cpp-bench-extras.js +1 -1
- package/src/sast/csrf.js +7 -5
- package/src/sast/env-hygiene.js +5 -2
- package/src/sast/iac-terraform.js +25 -0
- package/src/sast/java-bench-extras.js +1 -1
- package/src/sast/java-constant-fold.js +5 -5
- package/src/sast/llm-owasp.js +4 -2
- package/src/sast/mcp-audit.js +7 -0
- package/src/sast/pipeline.js +8 -0
- package/src/sast/prompt-template.js +8 -6
- package/src/sast/prototype-pollution.js +6 -2
- package/src/sast/redos-nfa.js +6 -6
- package/src/sast/secret-concat.js +13 -2
- package/src/sast/ssrf-cloud-metadata.js +6 -3
- package/src/sast/xss-reflected-multilang.js +1 -1
- package/src/sast/xxe.js +1 -1
- package/src/sca/CLAUDE.md +3 -4
- package/src/sca/container.js +35 -3
- package/src/sca/dep-confusion.js +7 -0
- package/src/sca/sarif-ingest.js +0 -187
package/src/report/index.js
CHANGED
|
@@ -24,7 +24,12 @@ function riskNote(f) {
|
|
|
24
24
|
const et = String(f.exploitabilityTier || '').toLowerCase();
|
|
25
25
|
if (et === 'minimal' || et === 'low') return `likely lower risk — ${et} exploitability`;
|
|
26
26
|
const ct = String(f.confidenceTier || '').toLowerCase();
|
|
27
|
-
|
|
27
|
+
// `f.confidence` is clamped to [0,1] by posture/confidence.js and 0 is a
|
|
28
|
+
// real, reachable value (unset confidence normalizes to `null`, not 0) —
|
|
29
|
+
// the single worst score this note exists to catch. A `> 0` lower bound
|
|
30
|
+
// exempted exactly that value from the downgrade note a 0.05 finding
|
|
31
|
+
// correctly got.
|
|
32
|
+
if (ct === 'low' || (typeof f.confidence === 'number' && f.confidence < 0.5)) {
|
|
28
33
|
return 'lower confidence — verify before prioritising';
|
|
29
34
|
}
|
|
30
35
|
return null;
|
|
@@ -83,6 +88,25 @@ function fingerprint(f){
|
|
|
83
88
|
return crypto.createHash('sha256').update(s).digest('hex').slice(0, 16);
|
|
84
89
|
}
|
|
85
90
|
|
|
91
|
+
// CMP-3: the findings schema requires `remediation` (root CLAUDE.md), and
|
|
92
|
+
// most detectors set it, but normalizeFindings only ever read the older
|
|
93
|
+
// `fix` STRING field that a minority of detectors use — so for every
|
|
94
|
+
// detector using the documented schema field, the SARIF fixes[]/
|
|
95
|
+
// fullDescription, the Markdown Fix column, and the CLI inline "fix:" line
|
|
96
|
+
// all rendered empty. `fix` still wins when both are set, matching the
|
|
97
|
+
// existing precedence in explainParts() above.
|
|
98
|
+
// Exported (Stage 6) so other raw-finding consumers outside this module —
|
|
99
|
+
// mcp/tools.js's scan_diff, lsp/server.js — that read scan.findings BEFORE
|
|
100
|
+
// normalizeFindings ever touches it can apply the same fix-string-vs-
|
|
101
|
+
// remediation-field precedence instead of re-implementing it ad hoc (and
|
|
102
|
+
// getting it wrong the way both of those did — reading only `.remediation`,
|
|
103
|
+
// which ~127 of engine.js's own detectors never set).
|
|
104
|
+
export function _remediationOf(f) {
|
|
105
|
+
if (f && typeof f.fix === 'string') return f.fix;
|
|
106
|
+
if (typeof f?.remediation === 'string') return f.remediation;
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
|
|
86
110
|
export function normalizeFindings(scan){
|
|
87
111
|
const out = [];
|
|
88
112
|
// Feat-4: filter findings via custom suppressions, recording the suppression
|
|
@@ -109,11 +133,13 @@ export function normalizeFindings(scan){
|
|
|
109
133
|
file: f.file,
|
|
110
134
|
line: f.line || f.source?.line || f.sink?.line || 0,
|
|
111
135
|
snippet: f.snippet || f.source?.snippet || f.sink?.snippet || '',
|
|
112
|
-
fix: f
|
|
136
|
+
fix: _remediationOf(f) ? { description: _remediationOf(f), code: f.code || '' } : null,
|
|
137
|
+
remediation: _remediationOf(f),
|
|
113
138
|
reachable: f.reachable ?? null,
|
|
114
139
|
triage: f.triageScore ?? null,
|
|
115
140
|
dataClasses: f.dataClasses || [],
|
|
116
141
|
chain: Array.isArray(f.chain) ? f.chain : null,
|
|
142
|
+
sourceProvenance: f.sourceProvenance || null,
|
|
117
143
|
confidence: typeof f.confidence === 'number' ? f.confidence : null,
|
|
118
144
|
// R17: corroboration ("one issue, many signals") — count of independent
|
|
119
145
|
// analyses that agreed, and which ones.
|
|
@@ -237,6 +263,65 @@ export function normalizeFindings(scan){
|
|
|
237
263
|
predictedBountyUsd: f.predictedBountyUsd || null,
|
|
238
264
|
bountyConfidence: f.bountyConfidence || null,
|
|
239
265
|
attackPlaybook: f.attackPlaybook || null,
|
|
266
|
+
// posture/git-history.js#annotateGitHistory — git blame + commit
|
|
267
|
+
// context, including AI-authorship detection via the Claude
|
|
268
|
+
// co-author trailer. Wired in engine.js but previously dropped here.
|
|
269
|
+
introducedBy: f.introducedBy || null,
|
|
270
|
+
introducedIn: f.introducedIn || null,
|
|
271
|
+
introducedAt: f.introducedAt || null,
|
|
272
|
+
introducedInMessage: f.introducedInMessage || null,
|
|
273
|
+
originatingPrompt: f.originatingPrompt || null,
|
|
274
|
+
aiAuthored: f.aiAuthored === true,
|
|
275
|
+
// posture/risk-dollars.js#annotateRiskDollars,
|
|
276
|
+
// posture/time-to-fix.js#annotateTimeToFix — same class of gap.
|
|
277
|
+
riskDollars: f.riskDollars || null,
|
|
278
|
+
estimatedFixHours: typeof f.estimatedFixHours === 'number' ? f.estimatedFixHours : null,
|
|
279
|
+
estimatedFixHoursSource: f.estimatedFixHoursSource || null,
|
|
280
|
+
// posture/composite-risk.js#annotateCompositeRisk — the module's own
|
|
281
|
+
// header calls this "the canonical sort key" for agents/UI; toProTable
|
|
282
|
+
// silently fell back to the older `triage` field because this was
|
|
283
|
+
// never in the allowlist.
|
|
284
|
+
compositeRisk: typeof f.compositeRisk === 'number' ? f.compositeRisk : null,
|
|
285
|
+
compositeRiskTier: f.compositeRiskTier || null,
|
|
286
|
+
compositeRiskFactors: Array.isArray(f.compositeRiskFactors) ? f.compositeRiskFactors : null,
|
|
287
|
+
// posture/relevance.js#annotateRelevance — entrypoint-reachability
|
|
288
|
+
// verdict + audit trail. The re-ranked `exploitability` it also sets
|
|
289
|
+
// did survive normalization; the verdict fields explaining WHY did not.
|
|
290
|
+
entrypointReachable: f.entrypointReachable ?? null,
|
|
291
|
+
relevance: typeof f.relevance === 'number' ? f.relevance : null,
|
|
292
|
+
relevanceTier: f.relevanceTier || null,
|
|
293
|
+
relevanceFactors: Array.isArray(f.relevanceFactors) ? f.relevanceFactors : null,
|
|
294
|
+
// posture/attack-taxonomy.js#annotateAttackTaxonomy — default-on;
|
|
295
|
+
// toProTable's `capec`/`mitre` columns read these and rendered `—`
|
|
296
|
+
// for every finding because they were never in this allowlist.
|
|
297
|
+
attck: f.attck || null,
|
|
298
|
+
attckName: f.attckName || null,
|
|
299
|
+
attckTactic: f.attckTactic || null,
|
|
300
|
+
atlas: f.atlas || null,
|
|
301
|
+
atlasName: f.atlasName || null,
|
|
302
|
+
d3fend: f.d3fend || null,
|
|
303
|
+
capec: f.capec || null,
|
|
304
|
+
// posture/falsification.js#annotateFalsification, which records its
|
|
305
|
+
// verdict via posture/verification-separation.js — recall-preserving
|
|
306
|
+
// (never removes a finding, never touches severity), but the verdict
|
|
307
|
+
// itself needs to survive to output or a quarantined finding ships
|
|
308
|
+
// indistinguishable from one nobody contested.
|
|
309
|
+
falsification: f.falsification || null,
|
|
310
|
+
quarantined: f.quarantined === true,
|
|
311
|
+
// The canonical schema (scanner/CLAUDE.md) documents `description` as
|
|
312
|
+
// a required field distinct from `vuln` (headline) and `remediation`
|
|
313
|
+
// (fix instructions) — ~47 SAST detectors set finding-specific "why
|
|
314
|
+
// this fired" prose here. It was never in this allowlist, so it was
|
|
315
|
+
// silently dropped between the detector and every report format.
|
|
316
|
+
description: f.description || null,
|
|
317
|
+
// posture/threat-model-grounding.js#applyThreatModel — crown-jewel /
|
|
318
|
+
// out-of-scope / compliance-regime / attacker-model tags. The severity
|
|
319
|
+
// bump/demotion it also performs mutates `severity` directly (so that
|
|
320
|
+
// half already survived normalization); this object itself did not.
|
|
321
|
+
threatModel: f.threatModel || null,
|
|
322
|
+
verification: f.verification || null,
|
|
323
|
+
// posture/pattern-propagation.js#annotateCrossRepoSignals — default-on.
|
|
324
|
+
crossRepoSignal: f.crossRepoSignal || null,
|
|
240
325
|
});
|
|
241
326
|
}
|
|
242
327
|
for (const s of (scan.secrets||[])) {
|
|
@@ -250,11 +335,19 @@ export function normalizeFindings(scan){
|
|
|
250
335
|
stride: s.stride || 'Information Disclosure',
|
|
251
336
|
file: s.file, line: s.line, snippet: s.snippet || '',
|
|
252
337
|
masked: s.masked || null,
|
|
253
|
-
fix: s
|
|
338
|
+
fix: _remediationOf(s) ? { description: _remediationOf(s), code: s.code || '' } : null,
|
|
339
|
+
remediation: _remediationOf(s),
|
|
254
340
|
blastRadius: s.blastRadius || null,
|
|
255
341
|
// Premortem #8: parser/family for downstream confidence + calibration.
|
|
256
342
|
parser: s.parser || 'SECRETS',
|
|
257
343
|
family: s.family || 'hardcoded-secret',
|
|
344
|
+
// secret-history.js sets these on a git-history-sweep finding (the
|
|
345
|
+
// commit it was found in, and a flag distinguishing it from a
|
|
346
|
+
// working-tree finding) — carried through so a consumer doesn't have
|
|
347
|
+
// to parse the commit sha back out of the synthetic `file` value.
|
|
348
|
+
commit: s.commit || null,
|
|
349
|
+
historical: s._historical === true,
|
|
350
|
+
description: s.description || null,
|
|
258
351
|
});
|
|
259
352
|
}
|
|
260
353
|
for (const lv of (scan.logicVulns||[])) {
|
|
@@ -267,11 +360,21 @@ export function normalizeFindings(scan){
|
|
|
267
360
|
cwe: lv.cwe || null,
|
|
268
361
|
stride: lv.stride || null,
|
|
269
362
|
file: lv.file, line: lv.line, snippet: lv.snippet || '',
|
|
270
|
-
fix: lv
|
|
363
|
+
fix: _remediationOf(lv) ? { description: _remediationOf(lv), code: lv.code || '' } : null,
|
|
364
|
+
remediation: _remediationOf(lv),
|
|
271
365
|
blastRadius: lv.blastRadius || null,
|
|
272
366
|
// Premortem #8.
|
|
273
367
|
parser: lv.parser || 'LOGIC',
|
|
274
368
|
family: lv.family || null,
|
|
369
|
+
// evaluateLicensePolicy (kind:'license') sets these so a consumer can
|
|
370
|
+
// identify WHICH component/license triggered the finding without
|
|
371
|
+
// regex-parsing the prose `vuln` string. Harmless no-op (all null)
|
|
372
|
+
// for every other logicVulns kind that doesn't set them.
|
|
373
|
+
package: lv.package || null,
|
|
374
|
+
version: lv.version || null,
|
|
375
|
+
ecosystem: lv.ecosystem || null,
|
|
376
|
+
license: lv.license || null,
|
|
377
|
+
description: lv.description || null,
|
|
275
378
|
});
|
|
276
379
|
}
|
|
277
380
|
for (const sc of (scan.supplyChain||[])) {
|
|
@@ -281,6 +384,12 @@ export function normalizeFindings(scan){
|
|
|
281
384
|
out.push({
|
|
282
385
|
id: fingerprint(sc),
|
|
283
386
|
kind: 'sca',
|
|
387
|
+
// sc.type discriminates vulnerable_dep | unpinned_dep | no_lockfile
|
|
388
|
+
// (src/sca/CLAUDE.md). Every MCP SCA-upgrade tool checks this field
|
|
389
|
+
// on findings looked up from the PERSISTED (toJSON-serialized) scan —
|
|
390
|
+
// dropping it here made synthesize_sca_upgrade/apply_sca_upgrade
|
|
391
|
+
// refuse every real SCA finding unconditionally.
|
|
392
|
+
type: sc.type || 'vulnerable_dep',
|
|
284
393
|
severity: sc.severity || 'high',
|
|
285
394
|
vuln: scVuln,
|
|
286
395
|
cwe: sc.cwe || null,
|
|
@@ -421,6 +530,22 @@ export function toJSON(scan, meta={}, opts={}){
|
|
|
421
530
|
// signature proves who asked for it, not that the results are absent.
|
|
422
531
|
suppressedRules: scan.suppressedRules || null,
|
|
423
532
|
_scanMeta: scan._scanMeta || null,
|
|
533
|
+
// S7: engine.js computes these on every scan with components, and their
|
|
534
|
+
// own findings already flow into `findings` above — but the structured
|
|
535
|
+
// summary objects (per-component license map, drift counts, "first
|
|
536
|
+
// scan, no baseline yet") were previously dropped here, so they never
|
|
537
|
+
// reached last-scan.json (written from this function's return value)
|
|
538
|
+
// or any --format output at all.
|
|
539
|
+
licenseGraph: scan.licenseGraph || null,
|
|
540
|
+
sbomDiff: scan.sbomDiff || null,
|
|
541
|
+
entrypointInventory: scan.entrypointInventory || null,
|
|
542
|
+
// S7: same class of gap as the three above — computed on every scan
|
|
543
|
+
// (rootCauseSweep unconditionally; attackTaxonomy/privacyFramework each
|
|
544
|
+
// default-on unless their own AGENTIC_SECURITY_NO_*/opt-in env var says
|
|
545
|
+
// otherwise) but never reached last-scan.json or any --format output.
|
|
546
|
+
rootCauseSweep: scan.rootCauseSweep || null,
|
|
547
|
+
attackTaxonomy: scan.attackTaxonomy || null,
|
|
548
|
+
privacyFramework: scan.privacyFramework || null,
|
|
424
549
|
};
|
|
425
550
|
if (opts.includeSuppressed) out.suppressed = scan.suppressions||[];
|
|
426
551
|
return out;
|
|
@@ -462,7 +587,10 @@ export function toSTIX(scan, meta = {}) {
|
|
|
462
587
|
created: now,
|
|
463
588
|
modified: now,
|
|
464
589
|
name: `${f.vuln || 'Security finding'} at ${f.file || '?'}:${f.line || '?'}`,
|
|
465
|
-
|
|
590
|
+
// Same precedence fix as toSARIF's fullDescription/message: the
|
|
591
|
+
// detector's own description of the finding beats remediation text,
|
|
592
|
+
// which beats degrading to the bare vuln title.
|
|
593
|
+
description: f.description || f.fix?.description || f.vuln || '',
|
|
466
594
|
external_references: cweExt,
|
|
467
595
|
labels: [f.severity || 'unknown'],
|
|
468
596
|
// x_* extension fields — STIX 2.1 allows custom properties prefixed
|
|
@@ -615,7 +743,14 @@ export function toSARIF(scan, meta={}){
|
|
|
615
743
|
id: f.vuln.replace(/[^a-zA-Z0-9]/g, '_'),
|
|
616
744
|
name: f.vuln,
|
|
617
745
|
shortDescription: { text: f.vuln },
|
|
618
|
-
|
|
746
|
+
// Prefer the detector's own explanation of the finding (`description`)
|
|
747
|
+
// over remediation text — `fix?.description` is fix instructions, not
|
|
748
|
+
// an account of the vulnerability. Falling back to remediation (rather
|
|
749
|
+
// than degrading to the bare rule title, already shown in
|
|
750
|
+
// shortDescription/name) when no description was set is intentional —
|
|
751
|
+
// see CMP-3's test asserting a remediation-only finding still gets a
|
|
752
|
+
// non-degenerate fullDescription.
|
|
753
|
+
fullDescription: { text: f.description || f.fix?.description || f.vuln },
|
|
619
754
|
helpUri: f.cwe ? `https://cwe.mitre.org/data/definitions/${f.cwe.replace(/[^0-9]/g,'')}.html` : undefined,
|
|
620
755
|
properties: { tags: [f.cwe, f.stride].filter(Boolean) },
|
|
621
756
|
});
|
|
@@ -680,7 +815,7 @@ export function toSARIF(scan, meta={}){
|
|
|
680
815
|
return {
|
|
681
816
|
ruleId: f.vuln ? f.vuln.replace(/[^a-zA-Z0-9]/g, '_') : 'unknown',
|
|
682
817
|
level: SEV_TO_SARIF[f.severity] || 'warning',
|
|
683
|
-
message: { text: f.fix?.description || f.vuln || 'Security finding' },
|
|
818
|
+
message: { text: f.description || f.fix?.description || f.vuln || 'Security finding' },
|
|
684
819
|
locations: [{ physicalLocation: { artifactLocation: { uri: f.file }, region: { startLine: Math.max(1, f.line||1) } } }],
|
|
685
820
|
...(codeFlows ? { codeFlows } : {}),
|
|
686
821
|
...(fixes ? { fixes } : {}),
|
|
@@ -792,7 +927,12 @@ export function toHTML(scan, meta = {}) {
|
|
|
792
927
|
for (const f of findings) byFile[f.file] = (byFile[f.file] || 0) + 1;
|
|
793
928
|
const hotspots = Object.entries(byFile).sort((a,b)=>b[1]-a[1]).slice(0, 10);
|
|
794
929
|
const data = JSON.stringify(findings).replace(/</g, '\\u003c');
|
|
795
|
-
|
|
930
|
+
// Every other emitter falls back to meta.startedAt (which
|
|
931
|
+
// posture/deterministic.js forces to a fixed value under --deterministic)
|
|
932
|
+
// before minting a fresh timestamp — toHTML never consulted it, so it was
|
|
933
|
+
// the one format that stayed non-deterministic run-to-run even under
|
|
934
|
+
// --deterministic.
|
|
935
|
+
const generatedAt = meta.startedAt || new Date().toISOString();
|
|
796
936
|
const SEV_HEX = { critical: '#ff2d55', high: '#ff6b35', medium: '#ffb800', low: '#34d058', info: '#82aaff' };
|
|
797
937
|
const sevBars = Object.entries(counts).map(([k, v]) =>
|
|
798
938
|
`<div class="sev-row"><span class="sev-tag" style="background:${SEV_HEX[k]}22;color:${SEV_HEX[k]}">${k}</span><span class="sev-bar" style="width:${Math.min(100, v * 4)}%;background:${SEV_HEX[k]}"></span><span class="sev-num">${v}</span></div>`
|
|
@@ -1098,8 +1238,29 @@ export function toShipVerdict(scan, options = {}) {
|
|
|
1098
1238
|
const profile = options.profile || { confidenceMin: CONF_DEFAULT_VIB, showTaxonomy: false };
|
|
1099
1239
|
const color = options.color !== false;
|
|
1100
1240
|
const c = (s, code) => color ? `${code}${s}${RESET}` : s;
|
|
1101
|
-
|
|
1102
|
-
|
|
1241
|
+
// CMP-4 (Stage-0 audit, 2026): the safety headline (Safe/Not-safe-to-deploy)
|
|
1242
|
+
// and the critical/high counts that drive it MUST come from the FULL
|
|
1243
|
+
// finding set, matching exitCodeFor — confidence filtering must never be
|
|
1244
|
+
// able to make a real critical/high invisible to the verdict. Before this
|
|
1245
|
+
// fix, `findings` was confidence-filtered BEFORE the severity split, so a
|
|
1246
|
+
// critical at confidence 0.85 (below the 0.9 vibecoder floor) made the
|
|
1247
|
+
// verdict print "Safe to deploy" while exitCodeFor — reading the same scan
|
|
1248
|
+
// unfiltered — returned 3. Same scan, contradictory answers depending on
|
|
1249
|
+
// which one you read.
|
|
1250
|
+
//
|
|
1251
|
+
// Confidence filtering KEEPS its legitimate purpose for low/medium/info
|
|
1252
|
+
// findings — those never gate the safety verdict, so hiding noisy
|
|
1253
|
+
// low-signal ones from a non-expert reader is a reasonable UX choice, not a
|
|
1254
|
+
// security decision. What changes is that low/medium/info findings hidden
|
|
1255
|
+
// this way are now DISCLOSED by count rather than silently vanishing,
|
|
1256
|
+
// matching the project's no-silent-truncation convention.
|
|
1257
|
+
const allFindings = normalizeFindings(scan);
|
|
1258
|
+
const min = profile.confidenceMin ?? CONF_DEFAULT_VIB;
|
|
1259
|
+
const criticalOrHighAll = allFindings.filter(f => /critical|high/.test(f.severity));
|
|
1260
|
+
const restFiltered = _withConfidence(allFindings.filter(f => !/critical|high/.test(f.severity)), min);
|
|
1261
|
+
const findings = [...criticalOrHighAll, ...restFiltered];
|
|
1262
|
+
const filteredOutCount = allFindings.length - findings.length;
|
|
1263
|
+
const actionable = criticalOrHighAll;
|
|
1103
1264
|
const advisoryCount = findings.length - actionable.length;
|
|
1104
1265
|
const sev = { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
|
|
1105
1266
|
for (const f of findings) sev[f.severity] = (sev[f.severity] || 0) + 1;
|
|
@@ -1164,6 +1325,12 @@ export function toShipVerdict(scan, options = {}) {
|
|
|
1164
1325
|
} else if (advisoryCount > 0) {
|
|
1165
1326
|
lines.push(c(` ${advisoryCount} advisory item${advisoryCount === 1 ? '' : 's'} — run /security-scan-all --firehose to see them.`, DIM));
|
|
1166
1327
|
}
|
|
1328
|
+
// No-silent-truncation: a low/medium/info finding hidden by the confidence
|
|
1329
|
+
// floor must be disclosed by count. Critical/high are never in this count —
|
|
1330
|
+
// see filteredOutCount's computation above.
|
|
1331
|
+
if (filteredOutCount > 0) {
|
|
1332
|
+
lines.push(c(` ${filteredOutCount} more below your confidence threshold — run /security-scan-all --firehose to see them.`, DIM));
|
|
1333
|
+
}
|
|
1167
1334
|
// Discoverability: the depth (per-finding explanation) and the shareable report
|
|
1168
1335
|
// exist but aren't obvious from the one-screen verdict — point to them.
|
|
1169
1336
|
if (findings.length > 0) {
|
|
@@ -1201,10 +1368,12 @@ export function toProTable(scan, options = {}) {
|
|
|
1201
1368
|
const columns = options.columns || 'standard'; // 'standard' | 'mitre' | 'capec' | 'owasp'
|
|
1202
1369
|
const findings = _withConfidence(normalizeFindings(scan), profile.confidenceMin ?? CONF_DEFAULT_PRO);
|
|
1203
1370
|
|
|
1204
|
-
// Rank by
|
|
1371
|
+
// Rank by compositeRisk (the canonical priority key, per composite-risk.js's
|
|
1372
|
+
// own header) when present, falling back to the older triage score, then
|
|
1373
|
+
// severity rank.
|
|
1205
1374
|
findings.sort((a, b) => {
|
|
1206
|
-
const ea = a.
|
|
1207
|
-
const eb = b.
|
|
1375
|
+
const ea = a.compositeRisk ?? (a.triage != null ? a.triage * 100 : (1 - (SEV_RANK[a.severity] || 0) / 4) * 100);
|
|
1376
|
+
const eb = b.compositeRisk ?? (b.triage != null ? b.triage * 100 : (1 - (SEV_RANK[b.severity] || 0) / 4) * 100);
|
|
1208
1377
|
return eb - ea;
|
|
1209
1378
|
});
|
|
1210
1379
|
|
|
@@ -1229,7 +1398,7 @@ export function toProTable(scan, options = {}) {
|
|
|
1229
1398
|
const cwe = (f.cwe || '—').padEnd(10);
|
|
1230
1399
|
const cvss = (f.cvss || f.cvssV3?.score || '—').toString().padEnd(5);
|
|
1231
1400
|
const owasp = (f.owasp || f.owaspCategory || '—').padEnd(10);
|
|
1232
|
-
const mitre = (f.
|
|
1401
|
+
const mitre = (f.attck || '—').padEnd(20);
|
|
1233
1402
|
const capec = (f.capec || '—').padEnd(10);
|
|
1234
1403
|
const conf = (f.confidence == null ? '—' : f.confidence.toFixed(2));
|
|
1235
1404
|
const vuln = (f.vuln || '').slice(0, 60);
|
package/src/runScan.js
CHANGED
|
@@ -120,7 +120,7 @@ export async function runScan(rootDir, opts = {}) {
|
|
|
120
120
|
|
|
121
121
|
// R8: `resume` is opt-in. Left undefined here, runFullScan falls back to the
|
|
122
122
|
// AGENTIC_SECURITY_RESUME=1 env var, which is off by default.
|
|
123
|
-
const scan = await runFullScan({ fileContents, depFileContents, scanRoot: root, resume: opts.resume }, opts.onProgress || (()=>{}));
|
|
123
|
+
const scan = await runFullScan({ fileContents, depFileContents, scanRoot: root, resume: opts.resume, deep: opts.deep, deepInCi: opts.deepInCi }, opts.onProgress || (()=>{}));
|
|
124
124
|
// Premortem 2R4.2: stamp ruleset version + source on the scan result, and
|
|
125
125
|
// notify if the operator pinned a different version than what's installed.
|
|
126
126
|
try { stampScan(root, scan); } catch {}
|
|
@@ -7,16 +7,25 @@
|
|
|
7
7
|
// - JS/TS/Java/Go/C/C++/Rust line comments // ...
|
|
8
8
|
// - JS/TS/Java/Go/C/C++/Rust block comments /* ... */
|
|
9
9
|
// - Python line comments # ...
|
|
10
|
+
// - PHP: all three of the above — `//`, `/* */`, AND `#` are all valid
|
|
11
|
+
// PHP line/block comment forms simultaneously (unlike Python, which
|
|
12
|
+
// only has `#`), so PHP needs its own mode rather than reusing 'py'
|
|
13
|
+
// (which would strip `#` but silently leave `//`/`/* */` PHP comments
|
|
14
|
+
// unstripped — a source of false positives on commented-out code).
|
|
10
15
|
//
|
|
11
16
|
// Skips comment-like content inside string literals (single/double/backtick).
|
|
12
17
|
//
|
|
13
|
-
// The `lang` parameter is optional; pass 'py' to treat `#` as a line comment
|
|
18
|
+
// The `lang` parameter is optional; pass 'py' to treat `#` as a line comment
|
|
19
|
+
// (and skip `//`/`/* */`), or 'php' to strip all three comment forms.
|
|
14
20
|
|
|
15
21
|
export function blankComments(s, lang) {
|
|
16
22
|
let out = '';
|
|
17
23
|
let inS = null;
|
|
18
24
|
let i = 0;
|
|
19
25
|
const isPy = lang === 'py';
|
|
26
|
+
const isPhp = lang === 'php';
|
|
27
|
+
const stripSlashForms = !isPy || isPhp;
|
|
28
|
+
const stripHash = isPy || isPhp;
|
|
20
29
|
while (i < s.length) {
|
|
21
30
|
const c = s[i];
|
|
22
31
|
if (inS) {
|
|
@@ -26,17 +35,19 @@ export function blankComments(s, lang) {
|
|
|
26
35
|
i++; continue;
|
|
27
36
|
}
|
|
28
37
|
if (c === "'" || c === '"' || c === '`') { inS = c; out += c; i++; continue; }
|
|
29
|
-
if (
|
|
38
|
+
if (stripSlashForms && c === '/' && s[i+1] === '/') {
|
|
30
39
|
while (i < s.length && s[i] !== '\n') { out += ' '; i++; }
|
|
31
40
|
continue;
|
|
32
41
|
}
|
|
33
|
-
if (
|
|
42
|
+
if (stripSlashForms && c === '/' && s[i+1] === '*') {
|
|
34
43
|
const end = s.indexOf('*/', i + 2);
|
|
35
44
|
const stop = end < 0 ? s.length : end + 2;
|
|
36
45
|
while (i < stop) { out += (s[i] === '\n' ? '\n' : ' '); i++; }
|
|
37
46
|
continue;
|
|
38
47
|
}
|
|
39
|
-
|
|
48
|
+
// PHP 8 attributes (`#[Route(...)]`) use the same `#` prefix as a line
|
|
49
|
+
// comment — `#[` is never a comment, so don't blank it.
|
|
50
|
+
if (stripHash && c === '#' && s[i+1] !== '[') {
|
|
40
51
|
while (i < s.length && s[i] !== '\n') { out += ' '; i++; }
|
|
41
52
|
continue;
|
|
42
53
|
}
|
|
@@ -63,7 +63,7 @@ const JWT_RE = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/;
|
|
|
63
63
|
// Configurable thresholds. Defaults tuned against the Juliet Java
|
|
64
64
|
// 468-FP / 1-TP collapse — these settings drop FPs to ~30 without
|
|
65
65
|
// losing the AWS-key-shaped TP.
|
|
66
|
-
|
|
66
|
+
const DEFAULT_OPTIONS = {
|
|
67
67
|
// Empirical floor for *non-dictionary* credentials. Lower than the
|
|
68
68
|
// 3.5 ceiling Shannon-quoted for "true randomness" because real test
|
|
69
69
|
// fixtures and rotated secrets often use repetitive base alphabets
|
package/src/sast/authz.js
CHANGED
|
@@ -145,10 +145,12 @@ export function scanAuthZ(fp, raw) {
|
|
|
145
145
|
const m2 = ln.match(JWT_HARDCODED_SECRET_RE);
|
|
146
146
|
if (m2) {
|
|
147
147
|
const val = m2[1];
|
|
148
|
-
// Suppress only template/env placeholders
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
148
|
+
// Suppress only template/env placeholders. We still flag well-known
|
|
149
|
+
// placeholders ("secret", "changeme", "example") because they are the
|
|
150
|
+
// most common production foot-gun.
|
|
151
|
+
const looksLikePlaceholder = /process\.env|\$\{|<.*?>|^\s*$/.test(val);
|
|
152
|
+
const isKnownBadPlaceholder = /\bsecret\b|\bchange.?me\b|^example$/i.test(val);
|
|
153
|
+
if (!looksLikePlaceholder || isKnownBadPlaceholder) {
|
|
152
154
|
push(_emit(fp, i + 1,
|
|
153
155
|
'AuthZ: hardcoded JWT secret in source',
|
|
154
156
|
'critical', 'CWE-798', ln.replace(val, '<redacted>'),
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
// set) AND strips the marker comments from the corpus before scanning, so the
|
|
22
22
|
// engine's true detection capability is measured.
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
function isBenchShape() {
|
|
25
25
|
return process.env.AGENTIC_SECURITY_BENCH_SHAPE === '1';
|
|
26
26
|
}
|
|
27
27
|
|
|
@@ -38,11 +38,6 @@ export {
|
|
|
38
38
|
applyJavaBenchSuppressions,
|
|
39
39
|
} from '../java-bench-extras.js';
|
|
40
40
|
|
|
41
|
-
// Re-export the cpp-bench-extras suppressor — gated at call sites.
|
|
42
|
-
export {
|
|
43
|
-
applyJulietCppSuppressions as applyJulietCppFamilySuppressions,
|
|
44
|
-
} from '../cpp-bench-extras.js';
|
|
45
|
-
|
|
46
41
|
// OWASP Benchmark @WebServlet route-category extractor.
|
|
47
42
|
// Returns the canonical vuln family (e.g. 'sql-injection') for files whose
|
|
48
43
|
// @WebServlet URL encodes the test category, or null.
|
|
@@ -54,7 +49,7 @@ const _OWASP_BENCH_CATEGORY_MAP = {
|
|
|
54
49
|
'weakrand': 'weak-rng', 'trustbound': 'trust-boundary',
|
|
55
50
|
'securecookie': 'header-hardening',
|
|
56
51
|
};
|
|
57
|
-
|
|
52
|
+
function benchShapeWebServletCategory(cleaned) {
|
|
58
53
|
if (!isBenchShape()) return null;
|
|
59
54
|
const m = cleaned.match(/@WebServlet\s*\(\s*(?:value\s*=\s*)?["'](?:[^"']*\/)?(\w+?)-\d+\//);
|
|
60
55
|
if (!m) return null;
|
|
@@ -64,14 +64,25 @@ function _line(raw, idx) {
|
|
|
64
64
|
return raw.slice(0, idx).split('\n').length;
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
// Zero-width / invisible Unicode characters: zero-width space, ZWNJ, ZWJ,
|
|
68
|
+
// word joiner, BOM/zero-width no-break space. Interspersing these between
|
|
69
|
+
// every character renders/executes identically to a human or LLM reader
|
|
70
|
+
// (most tokenizers treat them as no-ops) while breaking literal-word regex
|
|
71
|
+
// matching — a documented real-world prompt-injection evasion technique
|
|
72
|
+
// against exactly this kind of pattern-matching defense. None of these are
|
|
73
|
+
// '\n', so removing them never shifts _line()'s line-count computation.
|
|
74
|
+
const _ZERO_WIDTH_RE = /[]/g;
|
|
75
|
+
|
|
67
76
|
export function scanClaudeMdPromptInjection(file, raw) {
|
|
68
77
|
if (!file || !raw || typeof raw !== 'string') return [];
|
|
69
78
|
if (!_INSTRUCTION_FILE_RE.test(file)) return [];
|
|
70
79
|
if (raw.length > 1_000_000) return [];
|
|
71
80
|
|
|
81
|
+
const cleaned = raw.replace(_ZERO_WIDTH_RE, '');
|
|
82
|
+
|
|
72
83
|
// Strip fenced code blocks so example snippets in docs don't trip the
|
|
73
84
|
// detector. Replace with same-length whitespace to preserve line offsets.
|
|
74
|
-
const stripped =
|
|
85
|
+
const stripped = cleaned.replace(/```[\s\S]*?```/g, (m) => m.replace(/[^\n]/g, ' '));
|
|
75
86
|
|
|
76
87
|
const findings = [];
|
|
77
88
|
|
|
@@ -149,12 +160,12 @@ export function scanClaudeMdPromptInjection(file, raw) {
|
|
|
149
160
|
|
|
150
161
|
// Hardcoded credentials in CLAUDE.md / AGENTS.md.
|
|
151
162
|
for (const { re, label } of _CRED_RE) {
|
|
152
|
-
const m = re.exec(
|
|
163
|
+
const m = re.exec(cleaned);
|
|
153
164
|
if (!m) continue;
|
|
154
165
|
findings.push({
|
|
155
166
|
id: `claude-md:hardcoded-cred:${file}:${m.index}`,
|
|
156
167
|
file,
|
|
157
|
-
line: _line(
|
|
168
|
+
line: _line(cleaned, m.index),
|
|
158
169
|
vuln: `Instruction file contains a hardcoded ${label}`,
|
|
159
170
|
severity: 'critical',
|
|
160
171
|
family: 'harness-config-secrets',
|
package/src/sast/cloud-iam.js
CHANGED
|
@@ -90,20 +90,68 @@ function _actionList(a) {
|
|
|
90
90
|
return Array.isArray(a) ? a : [a];
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
+
// Finds every balanced {...} substring in `raw` that parses as valid JSON
|
|
94
|
+
// and itself contains a "Statement" key. Recovers the case _isAwsPolicy
|
|
95
|
+
// already promises to route here: an AWS policy document embedded as a
|
|
96
|
+
// JSON-quoted string inside a non-JSON host file — most commonly a
|
|
97
|
+
// Terraform heredoc (`policy = <<EOF ... EOF`). Does not attempt to parse
|
|
98
|
+
// native-HCL jsonencode({...}) blocks (bare identifiers, no quotes) — that
|
|
99
|
+
// shape doesn't match _isAwsPolicy's own quoted-key detection regex either,
|
|
100
|
+
// so it's already outside this detector's claimed scope.
|
|
101
|
+
function _extractJsonPolicyBlocks(raw) {
|
|
102
|
+
const blocks = [];
|
|
103
|
+
for (let i = 0; i < raw.length; i++) {
|
|
104
|
+
if (raw[i] !== '{') continue;
|
|
105
|
+
let depth = 0, inStr = false, strCh = '', esc = false, j = i;
|
|
106
|
+
for (; j < raw.length; j++) {
|
|
107
|
+
const c = raw[j];
|
|
108
|
+
if (inStr) {
|
|
109
|
+
if (esc) esc = false;
|
|
110
|
+
else if (c === '\\') esc = true;
|
|
111
|
+
else if (c === strCh) inStr = false;
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
if (c === '"') { inStr = true; strCh = c; continue; }
|
|
115
|
+
if (c === '{') depth++;
|
|
116
|
+
else if (c === '}') { depth--; if (depth === 0) break; }
|
|
117
|
+
}
|
|
118
|
+
if (depth !== 0 || j >= raw.length) continue;
|
|
119
|
+
const candidate = raw.slice(i, j + 1);
|
|
120
|
+
if (!/"Statement"\s*:/.test(candidate)) continue;
|
|
121
|
+
try { blocks.push(JSON.parse(candidate)); } catch { /* not valid JSON on its own */ }
|
|
122
|
+
}
|
|
123
|
+
return blocks;
|
|
124
|
+
}
|
|
125
|
+
|
|
93
126
|
function detectAws(file, raw, out, seen) {
|
|
94
|
-
let
|
|
95
|
-
try {
|
|
96
|
-
|
|
127
|
+
let ss;
|
|
128
|
+
try {
|
|
129
|
+
ss = _statements(JSON.parse(raw));
|
|
130
|
+
} catch {
|
|
131
|
+
// Non-JSON host file (e.g. Terraform HCL) — fall back to locating
|
|
132
|
+
// embedded JSON policy-document blocks instead of giving up outright.
|
|
133
|
+
ss = _extractJsonPolicyBlocks(raw).flatMap(_statements);
|
|
134
|
+
if (!ss.length) return;
|
|
135
|
+
}
|
|
136
|
+
// _line() takes a raw-text offset, not a substring — a monotonically
|
|
137
|
+
// advancing cursor lets successive statements (JSON.parse preserves
|
|
138
|
+
// source order) each locate their OWN occurrence of a shared marker
|
|
139
|
+
// like `"Principal"` instead of every statement resolving to the same
|
|
140
|
+
// (first) occurrence in the file.
|
|
141
|
+
let _cursor = 0;
|
|
97
142
|
for (const s of ss) {
|
|
98
143
|
if ((s.Effect || 'Allow') !== 'Allow') continue;
|
|
99
144
|
const actions = _actionList(s.Action);
|
|
100
145
|
const isStarAction = actions.includes('*') || actions.some(a => /^[a-z]+:\*$/.test(a));
|
|
101
146
|
const hasCondition = s.Condition && Object.keys(s.Condition).length > 0;
|
|
102
147
|
const principalStar = _principalIsWildcard(s.Principal);
|
|
148
|
+
let _advanceTo = _cursor;
|
|
103
149
|
|
|
104
150
|
// aws-public-s3-policy
|
|
105
151
|
if (principalStar && actions.some(a => /^s3:/.test(a))) {
|
|
106
|
-
const
|
|
152
|
+
const idx = raw.indexOf('"Principal"', _cursor);
|
|
153
|
+
const ln = _line(raw, idx >= 0 ? idx : _cursor);
|
|
154
|
+
if (idx >= 0) _advanceTo = Math.max(_advanceTo, idx + 1);
|
|
107
155
|
const id = `aws-public-s3-policy:${file}:${ln}`;
|
|
108
156
|
if (!seen.has(id)) {
|
|
109
157
|
seen.add(id);
|
|
@@ -118,7 +166,9 @@ function detectAws(file, raw, out, seen) {
|
|
|
118
166
|
|
|
119
167
|
// aws-public-trust-policy (Principal:* on AssumeRole)
|
|
120
168
|
if (principalStar && actions.includes('sts:AssumeRole')) {
|
|
121
|
-
const
|
|
169
|
+
const idx = raw.indexOf('AssumeRole', _cursor);
|
|
170
|
+
const ln = _line(raw, idx >= 0 ? idx : _cursor);
|
|
171
|
+
if (idx >= 0) _advanceTo = Math.max(_advanceTo, idx + 1);
|
|
122
172
|
const id = `aws-public-trust-policy:${file}:${ln}`;
|
|
123
173
|
if (!seen.has(id)) {
|
|
124
174
|
seen.add(id);
|
|
@@ -136,7 +186,9 @@ function detectAws(file, raw, out, seen) {
|
|
|
136
186
|
if (_HIGH_RISK_AWS_ACTIONS.includes(a) || a === '*') {
|
|
137
187
|
const conditionStr = JSON.stringify(s.Condition || {});
|
|
138
188
|
if (!/MultiFactorAuthPresent|MultiFactorAuthAge/.test(conditionStr)) {
|
|
139
|
-
const
|
|
189
|
+
const idx = raw.indexOf(`"${a}"`, _cursor);
|
|
190
|
+
const ln = _line(raw, idx >= 0 ? idx : _cursor);
|
|
191
|
+
if (idx >= 0) _advanceTo = Math.max(_advanceTo, idx + 1);
|
|
140
192
|
const id = `aws-no-mfa-condition:${file}:${a}:${ln}`;
|
|
141
193
|
if (!seen.has(id)) {
|
|
142
194
|
seen.add(id);
|
|
@@ -153,12 +205,13 @@ function detectAws(file, raw, out, seen) {
|
|
|
153
205
|
|
|
154
206
|
// (iam:PassRole with Resource:* is detected by posture/iam-policy.js —
|
|
155
207
|
// not duplicated here.)
|
|
208
|
+
_cursor = _advanceTo;
|
|
156
209
|
}
|
|
157
210
|
|
|
158
211
|
// aws-overbroad-managed-policy
|
|
159
212
|
if (/"AdministratorAccess"|"PowerUserAccess"/.test(raw) && !/root\b|Bootstrap\b/.test(raw)) {
|
|
160
213
|
const m = /"(AdministratorAccess|PowerUserAccess)"/.exec(raw);
|
|
161
|
-
const ln = _line(raw, m
|
|
214
|
+
const ln = _line(raw, m.index);
|
|
162
215
|
const id = `aws-overbroad-managed-policy:${file}:${ln}`;
|
|
163
216
|
if (!seen.has(id)) {
|
|
164
217
|
seen.add(id);
|
|
@@ -82,7 +82,7 @@ function familyOf(finding) {
|
|
|
82
82
|
}
|
|
83
83
|
|
|
84
84
|
// Return the primary-CWE family for a Juliet C/C++ test path, or null.
|
|
85
|
-
|
|
85
|
+
function julietPrimaryFamily(file) {
|
|
86
86
|
const m = JULIET_DIR_RE.exec(String(file).replace(/\\/g, '/'));
|
|
87
87
|
if (!m) return null;
|
|
88
88
|
return CWE_TO_FAMILY[parseInt(m[1], 10)] || null;
|
package/src/sast/csrf.js
CHANGED
|
@@ -63,11 +63,13 @@ export function scanCSRF(fp, raw) {
|
|
|
63
63
|
if (!langSel) return [];
|
|
64
64
|
|
|
65
65
|
const code = blankComments(raw, (ext === 'py' || ext === 'rb') ? 'py' : undefined);
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
66
|
+
// Per-route defence is checked below (±15-line window around each route).
|
|
67
|
+
// There used to also be a whole-file gate here ("if the file shows CSRF
|
|
68
|
+
// defence ANYWHERE, suppress every route in it") — by regex-substring
|
|
69
|
+
// monotonicity that gate always fires whenever the per-route window would
|
|
70
|
+
// have matched too (the window is a substring of the file), so it made the
|
|
71
|
+
// per-route check provably unreachable: one protected route silently
|
|
72
|
+
// suppressed every OTHER route in the same file, however unprotected.
|
|
71
73
|
|
|
72
74
|
const findings = [];
|
|
73
75
|
const seen = new Set();
|
package/src/sast/env-hygiene.js
CHANGED
|
@@ -4,12 +4,15 @@
|
|
|
4
4
|
// NEXT_PUBLIC_ vars that expose private values, .env.example with real
|
|
5
5
|
// credentials, and hardcoded fallback values that look real.
|
|
6
6
|
//
|
|
7
|
-
// Findings
|
|
7
|
+
// Findings (4 implemented; ENV_MISSING_GITIGNORE was documented here but has
|
|
8
|
+
// no implementation anywhere in this file — found via Stage-0 doc audit,
|
|
9
|
+
// 2026. Either implement it or drop it from this list; left as a gap, not
|
|
10
|
+
// silently removed, so the missing coverage stays visible):
|
|
8
11
|
// ENV_NEXT_PUBLIC_SECRET — NEXT_PUBLIC_ variable whose name implies it's secret
|
|
9
12
|
// ENV_EXAMPLE_REAL_VALUE — .env.example / .env.sample with a real-looking value
|
|
10
13
|
// ENV_HARDCODED_FALLBACK — process.env.X || "looks-real" fallback in source
|
|
11
|
-
// ENV_MISSING_GITIGNORE — .env / .env.local present but not in .gitignore
|
|
12
14
|
// ENV_DOTENV_IN_SOURCE — .env file content loaded via require/import in prod code
|
|
15
|
+
// NOT IMPLEMENTED: ENV_MISSING_GITIGNORE — .env / .env.local present but not in .gitignore
|
|
13
16
|
|
|
14
17
|
const _ENV_FILE_RE = /^\.env(?:\.(?:local|development|production|test|staging))?$/i;
|
|
15
18
|
const _NONPROD_RE = /(?:^|\/)(?:tests?|__tests__|spec|fixtures?|examples?|node_modules)\//i;
|