@clear-capabilities/agentic-security-scanner 0.136.2 → 0.136.9
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 +236 -0
- package/bin/agentic-security.js +186 -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 +183 -14
- 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 +14 -14
- 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 +18 -10
- package/src/dataflow/CLAUDE.md +10 -4
- package/src/dataflow/builtin-summaries.js +1 -1
- package/src/dataflow/engine.js +324 -60
- package/src/dataflow/implicit-flow.js +68 -36
- package/src/dataflow/incremental.js +18 -3
- package/src/dataflow/index.js +2 -1
- 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 +202 -42
- package/src/ir/CLAUDE.md +4 -1
- package/src/ir/balanced-call.js +55 -0
- package/src/ir/parser-cs.js +62 -9
- package/src/ir/parser-go.js +29 -11
- package/src/ir/parser-java.js +96 -19
- package/src/ir/parser-js.js +151 -20
- package/src/ir/parser-php.js +44 -9
- package/src/ir/parser-rb.js +37 -7
- package/src/ir/ssa.js +6 -1
- package/src/lsp/server.js +28 -2
- package/src/mcp/CLAUDE.md +9 -2
- package/src/mcp/redact.js +26 -0
- package/src/mcp/tools.js +155 -14
- 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/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/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/posture/verifier.js
CHANGED
|
@@ -17,9 +17,11 @@
|
|
|
17
17
|
// against a caller-provided target URL (AGENTIC_SECURITY_VERIFY_TARGET).
|
|
18
18
|
// Without a target, live mode falls back to validate-only with a
|
|
19
19
|
// `cannot-verify` verdict + reason 'no-target'.
|
|
20
|
-
// * Sandbox:
|
|
21
|
-
//
|
|
22
|
-
//
|
|
20
|
+
// * Sandbox: live execution runs through src/sandbox/index.js's confined
|
|
21
|
+
// execution facility (the same one execution-proof.js uses) — never a
|
|
22
|
+
// bare, unconfined subprocess. When no confinement primitive is
|
|
23
|
+
// available on the host, live verification refuses rather than falling
|
|
24
|
+
// back to running the PoC unconfined.
|
|
23
25
|
//
|
|
24
26
|
// Fail-closed semantics (FR-VER-7): any error — Docker missing, target down,
|
|
25
27
|
// PoC throws — produces `cannot-verify`, never `rejected`. An attacker who
|
|
@@ -28,7 +30,7 @@
|
|
|
28
30
|
import * as fs from 'node:fs';
|
|
29
31
|
import * as path from 'node:path';
|
|
30
32
|
import * as os from 'node:os';
|
|
31
|
-
import {
|
|
33
|
+
import { runConfined, sandboxAvailable } from '../sandbox/index.js';
|
|
32
34
|
import { isExplicitlyNoPoc } from './poc-cwe-map.js';
|
|
33
35
|
|
|
34
36
|
// ─── PoC static validation ──────────────────────────────────────────────────
|
|
@@ -122,18 +124,36 @@ export function proveSanitizerAbsence(finding, fileContents) {
|
|
|
122
124
|
function runSandboxed(poc, opts = {}) {
|
|
123
125
|
const target = opts.target;
|
|
124
126
|
if (!target) return { ok: false, reason: 'no-target' };
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
127
|
+
if (!sandboxAvailable() && !opts.force) {
|
|
128
|
+
return { ok: false, reason: 'no confinement primitive available on this host; refusing to execute the PoC unconfined', runner: 'disabled' };
|
|
129
|
+
}
|
|
130
|
+
// Materialise the PoC into a fresh sandbox root.
|
|
131
|
+
const dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'as-poc-')));
|
|
132
|
+
const file = poc.lang === 'python' ? 'poc.py' : 'poc.mjs';
|
|
128
133
|
try {
|
|
129
|
-
fs.writeFileSync(file, _patchTarget(poc.code, target));
|
|
134
|
+
fs.writeFileSync(path.join(dir, file), _patchTarget(poc.code, target));
|
|
130
135
|
} catch (e) {
|
|
136
|
+
try { fs.rmSync(dir, { recursive: true, force: true }); } catch {}
|
|
131
137
|
return { ok: false, reason: `write-failed:${e.message}` };
|
|
132
138
|
}
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
139
|
+
try {
|
|
140
|
+
const argv = poc.lang === 'python' ? ['python3', file] : [process.execPath, file];
|
|
141
|
+
// allowNetwork: the whole point of live verification is reaching the
|
|
142
|
+
// caller-provided target — writes and everything else stay confined.
|
|
143
|
+
const r = runConfined(argv, { root: dir, timeoutMs: opts.timeoutMs || 15000, allowNetwork: true, force: opts.force });
|
|
144
|
+
if (r.status === 'disabled') {
|
|
145
|
+
return { ok: false, reason: 'confined execution is disabled; the PoC was refused and never executed', runner: r.backend };
|
|
146
|
+
}
|
|
147
|
+
if (r.status === 'error') {
|
|
148
|
+
return { ok: false, reason: `sandbox-error:${(r.stderr || '').trim() || 'unknown'}`, runner: r.backend };
|
|
149
|
+
}
|
|
150
|
+
if (r.timedOut) {
|
|
151
|
+
return { ok: false, reason: 'poc-timeout', runner: r.backend };
|
|
152
|
+
}
|
|
153
|
+
return { ok: true, exitCode: r.exitCode, stderr: r.stderr || '', stdout: r.stdout || '', runner: r.backend, denied: r.denied };
|
|
154
|
+
} finally {
|
|
155
|
+
try { fs.rmSync(dir, { recursive: true, force: true }); } catch {}
|
|
156
|
+
}
|
|
137
157
|
}
|
|
138
158
|
|
|
139
159
|
function _patchTarget(code, target) {
|
|
@@ -141,51 +161,6 @@ function _patchTarget(code, target) {
|
|
|
141
161
|
return code.replace(/http:\/\/localhost:3000/g, target);
|
|
142
162
|
}
|
|
143
163
|
|
|
144
|
-
function _haveDocker() {
|
|
145
|
-
try {
|
|
146
|
-
const r = spawnSync('docker', ['version'], { stdio: 'ignore', timeout: 3000 });
|
|
147
|
-
return r.status === 0;
|
|
148
|
-
} catch { return false; }
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
function _runDocker(file, dir, lang, opts) {
|
|
152
|
-
const image = lang === 'python' ? 'python:3.12-slim' : 'node:22-slim';
|
|
153
|
-
const cmd = lang === 'python' ? ['python3', '/work/poc.py'] : ['node', '/work/poc.mjs'];
|
|
154
|
-
const args = [
|
|
155
|
-
'run', '--rm',
|
|
156
|
-
'--network=host', // PoC must reach the target; host is the smallest blast radius
|
|
157
|
-
'--cap-drop=ALL',
|
|
158
|
-
'--memory=256m',
|
|
159
|
-
'--cpu-quota=20000',
|
|
160
|
-
'--pids-limit=64',
|
|
161
|
-
'--read-only',
|
|
162
|
-
'--tmpfs=/tmp',
|
|
163
|
-
'--user', 'nobody',
|
|
164
|
-
'-v', `${dir}:/work:ro`,
|
|
165
|
-
image,
|
|
166
|
-
...cmd,
|
|
167
|
-
];
|
|
168
|
-
const r = spawnSync('docker', args, {
|
|
169
|
-
timeout: opts.timeoutMs || 15000,
|
|
170
|
-
encoding: 'utf8',
|
|
171
|
-
});
|
|
172
|
-
if (r.error) return { ok: false, reason: `docker-error:${r.error.code || r.error.message}`, runner: 'docker' };
|
|
173
|
-
return { ok: true, exitCode: r.status, stderr: r.stderr || '', stdout: r.stdout || '', runner: 'docker' };
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
function _runSubprocess(file, lang, opts) {
|
|
177
|
-
const bin = lang === 'python' ? 'python3' : 'node';
|
|
178
|
-
const r = spawnSync(bin, [file], {
|
|
179
|
-
timeout: opts.timeoutMs || 15000,
|
|
180
|
-
encoding: 'utf8',
|
|
181
|
-
// Best-effort containment without Docker. Operators are warned in stderr
|
|
182
|
-
// that the subprocess fallback offers materially weaker isolation.
|
|
183
|
-
env: { PATH: process.env.PATH || '', NODE_OPTIONS: '' },
|
|
184
|
-
});
|
|
185
|
-
if (r.error) return { ok: false, reason: `subprocess-error:${r.error.code || r.error.message}`, runner: 'subprocess' };
|
|
186
|
-
return { ok: true, exitCode: r.status, stderr: r.stderr || '', stdout: r.stdout || '', runner: 'subprocess' };
|
|
187
|
-
}
|
|
188
|
-
|
|
189
164
|
// ─── Per-finding verdict assignment ─────────────────────────────────────────
|
|
190
165
|
|
|
191
166
|
export function verdictForFinding(finding, ctx = {}) {
|
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',
|