@qa-ai-stlc/runner-security 0.0.0-stage → 1.7.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/README.md +52 -2
- package/dist/checks/authz-check.d.ts +9 -0
- package/dist/checks/authz-check.d.ts.map +1 -0
- package/dist/checks/authz-check.js +100 -0
- package/dist/checks/authz-check.js.map +1 -0
- package/dist/checks/code-check-support.d.ts +9 -0
- package/dist/checks/code-check-support.d.ts.map +1 -0
- package/dist/checks/code-check-support.js +19 -0
- package/dist/checks/code-check-support.js.map +1 -0
- package/dist/checks/configuration-check.d.ts +10 -0
- package/dist/checks/configuration-check.d.ts.map +1 -0
- package/dist/checks/configuration-check.js +128 -0
- package/dist/checks/configuration-check.js.map +1 -0
- package/dist/checks/cookies-check.d.ts +10 -0
- package/dist/checks/cookies-check.d.ts.map +1 -0
- package/dist/checks/cookies-check.js +113 -0
- package/dist/checks/cookies-check.js.map +1 -0
- package/dist/checks/cors-check.d.ts +10 -0
- package/dist/checks/cors-check.d.ts.map +1 -0
- package/dist/checks/cors-check.js +55 -0
- package/dist/checks/cors-check.js.map +1 -0
- package/dist/checks/csrf-check.d.ts +10 -0
- package/dist/checks/csrf-check.d.ts.map +1 -0
- package/dist/checks/csrf-check.js +108 -0
- package/dist/checks/csrf-check.js.map +1 -0
- package/dist/checks/dependencies-check.d.ts +10 -0
- package/dist/checks/dependencies-check.d.ts.map +1 -0
- package/dist/checks/dependencies-check.js +160 -0
- package/dist/checks/dependencies-check.js.map +1 -0
- package/dist/checks/encoding-check.d.ts +10 -0
- package/dist/checks/encoding-check.d.ts.map +1 -0
- package/dist/checks/encoding-check.js +67 -0
- package/dist/checks/encoding-check.js.map +1 -0
- package/dist/checks/errors-check.d.ts +8 -0
- package/dist/checks/errors-check.d.ts.map +1 -0
- package/dist/checks/errors-check.js +60 -0
- package/dist/checks/errors-check.js.map +1 -0
- package/dist/checks/headers-check.d.ts +9 -0
- package/dist/checks/headers-check.d.ts.map +1 -0
- package/dist/checks/headers-check.js +110 -0
- package/dist/checks/headers-check.js.map +1 -0
- package/dist/checks/secrets-check.d.ts +10 -0
- package/dist/checks/secrets-check.d.ts.map +1 -0
- package/dist/checks/secrets-check.js +113 -0
- package/dist/checks/secrets-check.js.map +1 -0
- package/dist/checks/session-check.d.ts +10 -0
- package/dist/checks/session-check.d.ts.map +1 -0
- package/dist/checks/session-check.js +87 -0
- package/dist/checks/session-check.js.map +1 -0
- package/dist/checks/session-pages.d.ts +11 -0
- package/dist/checks/session-pages.d.ts.map +1 -0
- package/dist/checks/session-pages.js +25 -0
- package/dist/checks/session-pages.js.map +1 -0
- package/dist/cookie-flags.d.ts +14 -0
- package/dist/cookie-flags.d.ts.map +1 -0
- package/dist/cookie-flags.js +28 -0
- package/dist/cookie-flags.js.map +1 -0
- package/dist/default-checks.d.ts +4 -0
- package/dist/default-checks.d.ts.map +1 -0
- package/dist/default-checks.js +28 -0
- package/dist/default-checks.js.map +1 -0
- package/dist/fetch-page.d.ts +19 -0
- package/dist/fetch-page.d.ts.map +1 -0
- package/dist/fetch-page.js +36 -0
- package/dist/fetch-page.js.map +1 -0
- package/dist/finding.d.ts +8 -0
- package/dist/finding.d.ts.map +1 -0
- package/dist/finding.js +14 -0
- package/dist/finding.js.map +1 -0
- package/dist/identity-sessions.d.ts +42 -0
- package/dist/identity-sessions.d.ts.map +1 -0
- package/dist/identity-sessions.js +102 -0
- package/dist/identity-sessions.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +10 -0
- package/dist/index.js.map +1 -0
- package/dist/security-audit.d.ts +30 -0
- package/dist/security-audit.d.ts.map +1 -0
- package/dist/security-audit.js +173 -0
- package/dist/security-audit.js.map +1 -0
- package/dist/security-check.d.ts +30 -0
- package/dist/security-check.d.ts.map +1 -0
- package/dist/security-check.js +4 -0
- package/dist/security-check.js.map +1 -0
- package/dist/security-probe.d.ts +56 -0
- package/dist/security-probe.d.ts.map +1 -0
- package/dist/security-probe.js +128 -0
- package/dist/security-probe.js.map +1 -0
- package/dist/source-checkout.d.ts +56 -0
- package/dist/source-checkout.d.ts.map +1 -0
- package/dist/source-checkout.js +140 -0
- package/dist/source-checkout.js.map +1 -0
- package/package.json +36 -4
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// Copyright The QA-AI-STLC Authors
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
import { scanForSecrets } from '@qa-ai-stlc/core';
|
|
4
|
+
import { buildFinding } from '../finding.js';
|
|
5
|
+
import { MAX_CITED_LINES_PER_FILE, isTestOrFixturePath, scannableLines } from './code-check-support.js';
|
|
6
|
+
// Shapes `scanForSecrets` does not cover. A hard-coded fallback such as `process.env.X ?? 'literal'`
|
|
7
|
+
// still ships the literal whenever the variable is unset, so it counts the same as a plain literal.
|
|
8
|
+
const LOCAL_SECRET_PATTERNS = [
|
|
9
|
+
{ name: 'private-key', regex: /-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP )?PRIVATE KEY(?: BLOCK)?-----/u },
|
|
10
|
+
{
|
|
11
|
+
name: 'hardcoded-secret',
|
|
12
|
+
regex: /\b\w*secret\w*["']?\s*[:=]\s*(?:[^'"\n]*(?:\?\?|\|\|)\s*)?["']([^"'\s]{8,})["']/iu,
|
|
13
|
+
literalGroup: 1,
|
|
14
|
+
},
|
|
15
|
+
];
|
|
16
|
+
// Names of environment variables (`QA_ADMIN_PASSWORD`) are how the project is told to hold a secret,
|
|
17
|
+
// not a secret.
|
|
18
|
+
const ENVIRONMENT_VARIABLE_NAME = /^[A-Z][A-Z0-9_]*$/u;
|
|
19
|
+
const PROVIDER_SHAPED = new Set([
|
|
20
|
+
'aws-access-key-id',
|
|
21
|
+
'github-token',
|
|
22
|
+
'slack-token',
|
|
23
|
+
'private-key',
|
|
24
|
+
]);
|
|
25
|
+
function findHits(lineText) {
|
|
26
|
+
const names = scanForSecrets(lineText).map(({ pattern }) => pattern);
|
|
27
|
+
for (const { name, regex, literalGroup } of LOCAL_SECRET_PATTERNS) {
|
|
28
|
+
const match = regex.exec(lineText);
|
|
29
|
+
const literal = literalGroup === undefined ? undefined : match?.[literalGroup];
|
|
30
|
+
if (match !== null && (literal === undefined || !ENVIRONMENT_VARIABLE_NAME.test(literal))) {
|
|
31
|
+
names.push(name);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return names.map((pattern) => ({ pattern, line: 0 }));
|
|
35
|
+
}
|
|
36
|
+
function confidenceFor(pattern, isTestPath) {
|
|
37
|
+
if (isTestPath) {
|
|
38
|
+
return 'low';
|
|
39
|
+
}
|
|
40
|
+
return PROVIDER_SHAPED.has(pattern) ? 'high' : 'medium';
|
|
41
|
+
}
|
|
42
|
+
function severityFor(pattern, isTestPath) {
|
|
43
|
+
if (isTestPath) {
|
|
44
|
+
return 'minor';
|
|
45
|
+
}
|
|
46
|
+
return PROVIDER_SHAPED.has(pattern) ? 'critical' : 'major';
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Looks through the source checkout for credentials committed with the code. Only the kind of
|
|
50
|
+
* secret and where it sits are reported, never the value, so the audit result and the defect draft
|
|
51
|
+
* carry no credential themselves. A match in test code or sample data is reported with low
|
|
52
|
+
* confidence. A clean result is `passed` only when every file was read: a truncated or partly
|
|
53
|
+
* skipped scan cannot show that nothing is there.
|
|
54
|
+
*/
|
|
55
|
+
export const secretsCheck = {
|
|
56
|
+
checkClass: 'secrets',
|
|
57
|
+
run: async ({ source }) => {
|
|
58
|
+
if (source === undefined) {
|
|
59
|
+
return { status: 'skipped', note: 'No source.path is configured.', findings: [] };
|
|
60
|
+
}
|
|
61
|
+
const scan = await source.scan();
|
|
62
|
+
const findings = [];
|
|
63
|
+
for (const file of scan.files) {
|
|
64
|
+
if (/(?:^|\/)(?:package-lock\.json|yarn\.lock|pnpm-lock\.yaml|npm-shrinkwrap\.json)$/u.test(file.path)) {
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
const linesByPattern = new Map();
|
|
68
|
+
for (const line of scannableLines(file.text)) {
|
|
69
|
+
for (const hit of findHits(line.text)) {
|
|
70
|
+
linesByPattern.set(hit.pattern, [...(linesByPattern.get(hit.pattern) ?? []), line.number]);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
const isTestPath = isTestOrFixturePath(file.path);
|
|
74
|
+
for (const [pattern, lines] of linesByPattern) {
|
|
75
|
+
findings.push(buildFinding({
|
|
76
|
+
idParts: ['secrets', pattern, file.path],
|
|
77
|
+
checkClass: 'secrets',
|
|
78
|
+
riskArea: 'Credential exposure in source',
|
|
79
|
+
confidence: confidenceFor(pattern, isTestPath),
|
|
80
|
+
severityProposal: severityFor(pattern, isTestPath),
|
|
81
|
+
title: `A ${pattern.replaceAll('-', ' ')} is committed in ${file.path}`,
|
|
82
|
+
steps: [`Open ${file.path}`, `Read line ${String(lines[0])}`],
|
|
83
|
+
expectedResult: 'Credentials are read from the environment or a secret manager and never committed with the code',
|
|
84
|
+
actualResult: `${file.path} holds a ${pattern.replaceAll('-', ' ')} on ${String(lines.length)} line(s); the value is not repeated here`,
|
|
85
|
+
remediation: 'Remove the value from the code and its history, rotate it, and read it from an environment variable or a secret manager.',
|
|
86
|
+
regressionCheck: `Run a secret scanner over the repository and assert it reports nothing in ${file.path}`,
|
|
87
|
+
requestIndexes: [],
|
|
88
|
+
codeReferences: lines
|
|
89
|
+
.slice(0, MAX_CITED_LINES_PER_FILE)
|
|
90
|
+
.map((lineNumber) => ({ path: file.path, line: lineNumber })),
|
|
91
|
+
}));
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return { ...outcomeFor(scan, findings.length), findings };
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
function outcomeFor(scan, findingCount) {
|
|
98
|
+
const summary = `Read ${String(scan.files.length)} file(s) for known secret shapes.`;
|
|
99
|
+
if (findingCount > 0) {
|
|
100
|
+
return { status: 'passed', note: summary };
|
|
101
|
+
}
|
|
102
|
+
if (scan.files.length === 0) {
|
|
103
|
+
return { status: 'uncertain', note: 'The source checkout has no readable text file.' };
|
|
104
|
+
}
|
|
105
|
+
if (scan.isTruncated || scan.skippedCount > 0) {
|
|
106
|
+
return {
|
|
107
|
+
status: 'uncertain',
|
|
108
|
+
note: `${summary} ${scan.isTruncated ? 'The file limit stopped the scan early. ' : ''}${String(scan.skippedCount)} binary or oversized file(s) were not read.`,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
return { status: 'passed', note: summary };
|
|
112
|
+
}
|
|
113
|
+
//# sourceMappingURL=secrets-check.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"secrets-check.js","sourceRoot":"","sources":["../../src/checks/secrets-check.ts"],"names":[],"mappings":"AAAA,mCAAmC;AACnC,sCAAsC;AAEtC,OAAO,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAC;AAElD,OAAO,EAAE,YAAY,EAAE,MAAM,eAAe,CAAC;AAE7C,OAAO,EAAE,wBAAwB,EAAE,mBAAmB,EAAE,cAAc,EAAE,MAAM,yBAAyB,CAAC;AASxG,qGAAqG;AACrG,oGAAoG;AACpG,MAAM,qBAAqB,GAAkC;IAC3D,EAAE,IAAI,EAAE,aAAa,EAAE,KAAK,EAAE,yEAAyE,EAAE;IACzG;QACE,IAAI,EAAE,kBAAkB;QACxB,KAAK,EAAE,mFAAmF;QAC1F,YAAY,EAAE,CAAC;KAChB;CACF,CAAC;AAEF,qGAAqG;AACrG,gBAAgB;AAChB,MAAM,yBAAyB,GAAG,oBAAoB,CAAC;AAEvD,MAAM,eAAe,GAAwB,IAAI,GAAG,CAAC;IACnD,mBAAmB;IACnB,cAAc;IACd,aAAa;IACb,aAAa;CACd,CAAC,CAAC;AAOH,SAAS,QAAQ,CAAC,QAAgB;IAChC,MAAM,KAAK,GAAG,cAAc,CAAC,QAAQ,CAAC,CAAC,GAAG,CAAC,CAAC,EAAE,OAAO,EAAE,EAAE,EAAE,CAAC,OAAO,CAAC,CAAC;IACrE,KAAK,MAAM,EAAE,IAAI,EAAE,KAAK,EAAE,YAAY,EAAE,IAAI,qBAAqB,EAAE,CAAC;QAClE,MAAM,KAAK,GAAG,KAAK,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC;QACnC,MAAM,OAAO,GAAG,YAAY,KAAK,SAAS,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,KAAK,EAAE,CAAC,YAAY,CAAC,CAAC;QAC/E,IAAI,KAAK,KAAK,IAAI,IAAI,CAAC,OAAO,KAAK,SAAS,IAAI,CAAC,yBAAyB,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC,EAAE,CAAC;YAC1F,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;QACnB,CAAC;IACH,CAAC;IACD,OAAO,KAAK,CAAC,GAAG,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,CAAC,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC,EAAE,CAAC,CAAC,CAAC;AACxD,CAAC;AAED,SAAS,aAAa,CAAC,OAAe,EAAE,UAAmB;IACzD,IAAI,UAAU,EAAE,CAAC;QACf,OAAO,KAAK,CAAC;IACf,CAAC;IACD,OAAO,eAAe,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,QAAQ,CAAC;AAC1D,CAAC;AAED,SAAS,WAAW,CAAC,OAAe,EAAE,UAAmB;IACvD,IAAI,UAAU,EAAE,CAAC;QACf,OAAO,OAAO,CAAC;IACjB,CAAC;IACD,OAAO,eAAe,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,UAAU,CAAC,CAAC,CAAC,OAAO,CAAC;AAC7D,CAAC;AAED;;;;;;GAMG;AACH,MAAM,CAAC,MAAM,YAAY,GAAkB;IACzC,UAAU,EAAE,SAAS;IACrB,GAAG,EAAE,KAAK,EAAE,EAAE,MAAM,EAAE,EAAiC,EAAE;QACvD,IAAI,MAAM,KAAK,SAAS,EAAE,CAAC;YACzB,OAAO,EAAE,MAAM,EAAE,SAAS,EAAE,IAAI,EAAE,+BAA+B,EAAE,QAAQ,EAAE,EAAE,EAAE,CAAC;QACpF,CAAC;QACD,MAAM,IAAI,GAAG,MAAM,MAAM,CAAC,IAAI,EAAE,CAAC;QACjC,MAAM,QAAQ,GAAsB,EAAE,CAAC;QACvC,KAAK,MAAM,IAAI,IAAI,IAAI,CAAC,KAAK,EAAE,CAAC;YAC9B,IACE,kFAAkF,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,EAClG,CAAC;gBACD,SAAS;YACX,CAAC;YACD,MAAM,cAAc,GAAG,IAAI,GAAG,EAAoB,CAAC;YACnD,KAAK,MAAM,IAAI,IAAI,cAAc,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC;gBAC7C,KAAK,MAAM,GAAG,IAAI,QAAQ,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC;oBACtC,cAAc,CAAC,GAAG,CAAC,GAAG,CAAC,OAAO,EAAE,CAAC,GAAG,CAAC,cAAc,CAAC,GAAG,CAAC,GAAG,CAAC,OAAO,CAAC,IAAI,EAAE,CAAC,EAAE,IAAI,CAAC,MAAM,CAAC,CAAC,CAAC;gBAC7F,CAAC;YACH,CAAC;YACD,MAAM,UAAU,GAAG,mBAAmB,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;YAClD,KAAK,MAAM,CAAC,OAAO,EAAE,KAAK,CAAC,IAAI,cAAc,EAAE,CAAC;gBAC9C,QAAQ,CAAC,IAAI,CACX,YAAY,CAAC;oBACX,OAAO,EAAE,CAAC,SAAS,EAAE,OAAO,EAAE,IAAI,CAAC,IAAI,CAAC;oBACxC,UAAU,EAAE,SAAS;oBACrB,QAAQ,EAAE,+BAA+B;oBACzC,UAAU,EAAE,aAAa,CAAC,OAAO,EAAE,UAAU,CAAC;oBAC9C,gBAAgB,EAAE,WAAW,CAAC,OAAO,EAAE,UAAU,CAAC;oBAClD,KAAK,EAAE,KAAK,OAAO,CAAC,UAAU,CAAC,GAAG,EAAE,GAAG,CAAC,oBAAoB,IAAI,CAAC,IAAI,EAAE;oBACvE,KAAK,EAAE,CAAC,QAAQ,IAAI,CAAC,IAAI,EAAE,EAAE,aAAa,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC;oBAC7D,cAAc,EACZ,iGAAiG;oBACnG,YAAY,EAAE,GAAG,IAAI,CAAC,IAAI,YAAY,OAAO,CAAC,UAAU,CAAC,GAAG,EAAE,GAAG,CAAC,OAAO,MAAM,CAAC,KAAK,CAAC,MAAM,CAAC,0CAA0C;oBACvI,WAAW,EACT,0HAA0H;oBAC5H,eAAe,EAAE,6EAA6E,IAAI,CAAC,IAAI,EAAE;oBACzG,cAAc,EAAE,EAAE;oBAClB,cAAc,EAAE,KAAK;yBAClB,KAAK,CAAC,CAAC,EAAE,wBAAwB,CAAC;yBAClC,GAAG,CAAC,CAAC,UAAU,EAAE,EAAE,CAAC,CAAC,EAAE,IAAI,EAAE,IAAI,CAAC,IAAI,EAAE,IAAI,EAAE,UAAU,EAAE,CAAC,CAAC;iBAChE,CAAC,CACH,CAAC;YACJ,CAAC;QACH,CAAC;QACD,OAAO,EAAE,GAAG,UAAU,CAAC,IAAI,EAAE,QAAQ,CAAC,MAAM,CAAC,EAAE,QAAQ,EAAE,CAAC;IAC5D,CAAC;CACF,CAAC;AAEF,SAAS,UAAU,CACjB,IAA0G,EAC1G,YAAoB;IAEpB,MAAM,OAAO,GAAG,QAAQ,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,MAAM,CAAC,mCAAmC,CAAC;IACrF,IAAI,YAAY,GAAG,CAAC,EAAE,CAAC;QACrB,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE,OAAO,EAAE,CAAC;IAC7C,CAAC;IACD,IAAI,IAAI,CAAC,KAAK,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QAC5B,OAAO,EAAE,MAAM,EAAE,WAAW,EAAE,IAAI,EAAE,gDAAgD,EAAE,CAAC;IACzF,CAAC;IACD,IAAI,IAAI,CAAC,WAAW,IAAI,IAAI,CAAC,YAAY,GAAG,CAAC,EAAE,CAAC;QAC9C,OAAO;YACL,MAAM,EAAE,WAAW;YACnB,IAAI,EAAE,GAAG,OAAO,IAAI,IAAI,CAAC,WAAW,CAAC,CAAC,CAAC,yCAAyC,CAAC,CAAC,CAAC,EAAE,GAAG,MAAM,CAAC,IAAI,CAAC,YAAY,CAAC,6CAA6C;SAC/J,CAAC;IACJ,CAAC;IACD,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE,OAAO,EAAE,CAAC;AAC7C,CAAC"}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { SecurityCheck } from '../security-check.js';
|
|
2
|
+
/**
|
|
3
|
+
* Checks that signing out ends the session on the server. With a signed-in identity it confirms
|
|
4
|
+
* the session works, signs out through the path the authorization names, then replays the old
|
|
5
|
+
* session cookie: a page that still opens means the session outlives its sign-out and a stolen
|
|
6
|
+
* cookie stays usable. Sign-out is a POST only if the authorization lists it as a mutation, a GET
|
|
7
|
+
* otherwise. This ends the identity's session, so it runs after the checks that use it.
|
|
8
|
+
*/
|
|
9
|
+
export declare const sessionCheck: SecurityCheck;
|
|
10
|
+
//# sourceMappingURL=session-check.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"session-check.d.ts","sourceRoot":"","sources":["../../src/checks/session-check.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,aAAa,EAAwB,MAAM,sBAAsB,CAAC;AAKhF;;;;;;GAMG;AACH,eAAO,MAAM,YAAY,EAAE,aA4E1B,CAAC"}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Copyright The QA-AI-STLC Authors
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
import { fetchPage } from '../fetch-page.js';
|
|
4
|
+
import { buildFinding } from '../finding.js';
|
|
5
|
+
import { isAccepted, sessionHeaders } from './session-pages.js';
|
|
6
|
+
const DEFAULT_PAGE = '/';
|
|
7
|
+
/**
|
|
8
|
+
* Checks that signing out ends the session on the server. With a signed-in identity it confirms
|
|
9
|
+
* the session works, signs out through the path the authorization names, then replays the old
|
|
10
|
+
* session cookie: a page that still opens means the session outlives its sign-out and a stolen
|
|
11
|
+
* cookie stays usable. Sign-out is a POST only if the authorization lists it as a mutation, a GET
|
|
12
|
+
* otherwise. This ends the identity's session, so it runs after the checks that use it.
|
|
13
|
+
*/
|
|
14
|
+
export const sessionCheck = {
|
|
15
|
+
checkClass: 'session',
|
|
16
|
+
run: async ({ probe, authorization, identities }) => {
|
|
17
|
+
const { logoutPath, restrictedRoutes } = authorization;
|
|
18
|
+
if (logoutPath === undefined) {
|
|
19
|
+
return { status: 'skipped', note: 'The authorization names no sign-out path.', findings: [] };
|
|
20
|
+
}
|
|
21
|
+
const session = identities.byRole('low') ?? identities.all()[0];
|
|
22
|
+
if (session === undefined) {
|
|
23
|
+
const failures = identities.describeFailures();
|
|
24
|
+
return failures === undefined
|
|
25
|
+
? { status: 'skipped', note: 'The check needs a signed-in identity.', findings: [] }
|
|
26
|
+
: { status: 'blocked', note: `The check needs a signed-in identity (${failures})`, findings: [] };
|
|
27
|
+
}
|
|
28
|
+
const { baseUrl } = authorization.environment;
|
|
29
|
+
const headers = sessionHeaders(session);
|
|
30
|
+
// Only a page that a signed-in visitor opens and an anonymous one does not can show whether the
|
|
31
|
+
// session still counts; on a public page an old cookie would "still work" with no flaw at all.
|
|
32
|
+
let indicator;
|
|
33
|
+
for (const candidate of [...restrictedRoutes, DEFAULT_PAGE]) {
|
|
34
|
+
const signedIn = await fetchPage(probe, baseUrl, candidate, { headers });
|
|
35
|
+
if (!isAccepted(signedIn)) {
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
const anonymous = await fetchPage(probe, baseUrl, candidate);
|
|
39
|
+
if (!isAccepted(anonymous)) {
|
|
40
|
+
indicator = { page: candidate, firstResponseIndex: signedIn.response.requestIndex };
|
|
41
|
+
break;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
if (indicator === undefined) {
|
|
45
|
+
return {
|
|
46
|
+
status: 'uncertain',
|
|
47
|
+
note: 'No page tells a signed-in visitor from an anonymous one, so the end of the session could not be judged.',
|
|
48
|
+
findings: [],
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
const { page, firstResponseIndex } = indicator;
|
|
52
|
+
const method = authorization.allowedMutations.find((mutation) => mutation.path === logoutPath)?.method ?? 'GET';
|
|
53
|
+
const signOut = await probe.request({ method, path: logoutPath, headers });
|
|
54
|
+
const after = await fetchPage(probe, baseUrl, page, { headers });
|
|
55
|
+
if (!isAccepted(after)) {
|
|
56
|
+
return {
|
|
57
|
+
status: 'passed',
|
|
58
|
+
note: `The old session was refused on ${page} after signing out.`,
|
|
59
|
+
findings: [],
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
return {
|
|
63
|
+
status: 'passed',
|
|
64
|
+
findings: [
|
|
65
|
+
buildFinding({
|
|
66
|
+
idParts: ['session', 'logout-not-invalidated'],
|
|
67
|
+
checkClass: 'session',
|
|
68
|
+
riskArea: 'Session management',
|
|
69
|
+
confidence: 'high',
|
|
70
|
+
severityProposal: 'major',
|
|
71
|
+
title: 'A session stays valid after signing out',
|
|
72
|
+
steps: [
|
|
73
|
+
`Sign in and open ${page}`,
|
|
74
|
+
`Sign out with ${method} ${logoutPath}`,
|
|
75
|
+
`Open ${page} again with the session cookie from before signing out`,
|
|
76
|
+
],
|
|
77
|
+
expectedResult: 'The old session cookie is refused or sent to sign in',
|
|
78
|
+
actualResult: `${page} still answered ${String(after.response.status)} for the old session`,
|
|
79
|
+
remediation: 'End the session on the server when the user signs out, not only in the browser.',
|
|
80
|
+
regressionCheck: `Assert that a session cookie from before ${logoutPath} is refused afterwards`,
|
|
81
|
+
requestIndexes: [firstResponseIndex, signOut.requestIndex, after.response.requestIndex],
|
|
82
|
+
}),
|
|
83
|
+
],
|
|
84
|
+
};
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
//# sourceMappingURL=session-check.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"session-check.js","sourceRoot":"","sources":["../../src/checks/session-check.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { FetchedPage } from '../fetch-page.js';
|
|
2
|
+
import type { IdentitySession } from '../identity-sessions.js';
|
|
3
|
+
/** The request headers that replay a signed-in session; they carry a credential and are never logged. */
|
|
4
|
+
export declare function sessionHeaders(session: IdentitySession): Readonly<Record<string, string>>;
|
|
5
|
+
/** Whether `page` is a real page of the application rather than a failure or a sign-in page. */
|
|
6
|
+
export declare function isAccepted(page: FetchedPage): boolean;
|
|
7
|
+
/** Whether `page` is the page that was asked for, after any same-origin redirects. */
|
|
8
|
+
export declare function reachedRoute(page: FetchedPage, route: string): boolean;
|
|
9
|
+
/** Whether a redirect location leads to a sign-in page. */
|
|
10
|
+
export declare function isSignInLocation(location: string | undefined): boolean;
|
|
11
|
+
//# sourceMappingURL=session-pages.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"session-pages.d.ts","sourceRoot":"","sources":["../../src/checks/session-pages.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC;AACpD,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,yBAAyB,CAAC;AAM/D,yGAAyG;AACzG,wBAAgB,cAAc,CAAC,OAAO,EAAE,eAAe,GAAG,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAEzF;AAMD,gGAAgG;AAChG,wBAAgB,UAAU,CAAC,IAAI,EAAE,WAAW,GAAG,OAAO,CAIrD;AAED,sFAAsF;AACtF,wBAAgB,YAAY,CAAC,IAAI,EAAE,WAAW,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAEtE;AAED,2DAA2D;AAC3D,wBAAgB,gBAAgB,CAAC,QAAQ,EAAE,MAAM,GAAG,SAAS,GAAG,OAAO,CAEtE"}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// Copyright The QA-AI-STLC Authors
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
// A page that asks the visitor to sign in. Applications name it differently, so this is a broad
|
|
4
|
+
// reading of the path, used only to tell "the application let me in" from "it sent me to sign in".
|
|
5
|
+
const SIGN_IN_PATH = /login|signin|sign-in|sso|auth/iu;
|
|
6
|
+
/** The request headers that replay a signed-in session; they carry a credential and are never logged. */
|
|
7
|
+
export function sessionHeaders(session) {
|
|
8
|
+
return { cookie: session.cookieHeader };
|
|
9
|
+
}
|
|
10
|
+
function pathnameOf(path) {
|
|
11
|
+
return new URL(path, 'http://placeholder.invalid').pathname;
|
|
12
|
+
}
|
|
13
|
+
/** Whether `page` is a real page of the application rather than a failure or a sign-in page. */
|
|
14
|
+
export function isAccepted(page) {
|
|
15
|
+
return (page.response.status >= 200 && page.response.status < 300 && !SIGN_IN_PATH.test(pathnameOf(page.path)));
|
|
16
|
+
}
|
|
17
|
+
/** Whether `page` is the page that was asked for, after any same-origin redirects. */
|
|
18
|
+
export function reachedRoute(page, route) {
|
|
19
|
+
return isAccepted(page) && pathnameOf(page.path) === pathnameOf(route);
|
|
20
|
+
}
|
|
21
|
+
/** Whether a redirect location leads to a sign-in page. */
|
|
22
|
+
export function isSignInLocation(location) {
|
|
23
|
+
return location !== undefined && SIGN_IN_PATH.test(pathnameOf(location));
|
|
24
|
+
}
|
|
25
|
+
//# sourceMappingURL=session-pages.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"session-pages.js","sourceRoot":"","sources":["../../src/checks/session-pages.ts"],"names":[],"mappings":"AAAA,mCAAmC;AACnC,sCAAsC;AAKtC,gGAAgG;AAChG,mGAAmG;AACnG,MAAM,YAAY,GAAG,iCAAiC,CAAC;AAEvD,yGAAyG;AACzG,MAAM,UAAU,cAAc,CAAC,OAAwB;IACrD,OAAO,EAAE,MAAM,EAAE,OAAO,CAAC,YAAY,EAAE,CAAC;AAC1C,CAAC;AAED,SAAS,UAAU,CAAC,IAAY;IAC9B,OAAO,IAAI,GAAG,CAAC,IAAI,EAAE,4BAA4B,CAAC,CAAC,QAAQ,CAAC;AAC9D,CAAC;AAED,gGAAgG;AAChG,MAAM,UAAU,UAAU,CAAC,IAAiB;IAC1C,OAAO,CACL,IAAI,CAAC,QAAQ,CAAC,MAAM,IAAI,GAAG,IAAI,IAAI,CAAC,QAAQ,CAAC,MAAM,GAAG,GAAG,IAAI,CAAC,YAAY,CAAC,IAAI,CAAC,UAAU,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,CACvG,CAAC;AACJ,CAAC;AAED,sFAAsF;AACtF,MAAM,UAAU,YAAY,CAAC,IAAiB,EAAE,KAAa;IAC3D,OAAO,UAAU,CAAC,IAAI,CAAC,IAAI,UAAU,CAAC,IAAI,CAAC,IAAI,CAAC,KAAK,UAAU,CAAC,KAAK,CAAC,CAAC;AACzE,CAAC;AAED,2DAA2D;AAC3D,MAAM,UAAU,gBAAgB,CAAC,QAA4B;IAC3D,OAAO,QAAQ,KAAK,SAAS,IAAI,YAAY,CAAC,IAAI,CAAC,UAAU,CAAC,QAAQ,CAAC,CAAC,CAAC;AAC3E,CAAC"}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export interface CookieFlags {
|
|
2
|
+
readonly name: string;
|
|
3
|
+
readonly isHttpOnly: boolean;
|
|
4
|
+
readonly isSecure: boolean;
|
|
5
|
+
/** Lower-cased `lax`, `strict` or `none`; `undefined` when the cookie names no SameSite policy. */
|
|
6
|
+
readonly sameSite: string | undefined;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Reads the name and protective attributes from one `Set-Cookie` header value. The value itself is
|
|
10
|
+
* never read into the result: a session cookie must not reach a finding, a log or evidence.
|
|
11
|
+
*/
|
|
12
|
+
export declare function parseSetCookie(header: string): CookieFlags | undefined;
|
|
13
|
+
export declare function looksLikeSessionCookie(name: string): boolean;
|
|
14
|
+
//# sourceMappingURL=cookie-flags.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cookie-flags.d.ts","sourceRoot":"","sources":["../src/cookie-flags.ts"],"names":[],"mappings":"AAGA,MAAM,WAAW,WAAW;IAC1B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,UAAU,EAAE,OAAO,CAAC;IAC7B,QAAQ,CAAC,QAAQ,EAAE,OAAO,CAAC;IAC3B,mGAAmG;IACnG,QAAQ,CAAC,QAAQ,EAAE,MAAM,GAAG,SAAS,CAAC;CACvC;AAED;;;GAGG;AACH,wBAAgB,cAAc,CAAC,MAAM,EAAE,MAAM,GAAG,WAAW,GAAG,SAAS,CActE;AAMD,wBAAgB,sBAAsB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAE5D"}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Copyright The QA-AI-STLC Authors
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
/**
|
|
4
|
+
* Reads the name and protective attributes from one `Set-Cookie` header value. The value itself is
|
|
5
|
+
* never read into the result: a session cookie must not reach a finding, a log or evidence.
|
|
6
|
+
*/
|
|
7
|
+
export function parseSetCookie(header) {
|
|
8
|
+
const [pair = '', ...attributes] = header.split(';').map((part) => part.trim());
|
|
9
|
+
const name = pair.slice(0, Math.max(pair.indexOf('='), 0)).trim();
|
|
10
|
+
if (name.length === 0) {
|
|
11
|
+
return undefined;
|
|
12
|
+
}
|
|
13
|
+
const lowered = attributes.map((attribute) => attribute.toLowerCase());
|
|
14
|
+
const sameSiteAttribute = lowered.find((attribute) => attribute.startsWith('samesite='));
|
|
15
|
+
return {
|
|
16
|
+
name,
|
|
17
|
+
isHttpOnly: lowered.includes('httponly'),
|
|
18
|
+
isSecure: lowered.includes('secure'),
|
|
19
|
+
sameSite: sameSiteAttribute?.slice('samesite='.length),
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
// A cookie that carries a login. Names differ by framework, so this is a deliberately broad guess
|
|
23
|
+
// used only to rank severity, never to decide whether something is reported.
|
|
24
|
+
const SESSION_COOKIE_NAME = /sess|sid|token|auth|jwt|login/iu;
|
|
25
|
+
export function looksLikeSessionCookie(name) {
|
|
26
|
+
return SESSION_COOKIE_NAME.test(name);
|
|
27
|
+
}
|
|
28
|
+
//# sourceMappingURL=cookie-flags.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cookie-flags.js","sourceRoot":"","sources":["../src/cookie-flags.ts"],"names":[],"mappings":"AAAA,mCAAmC;AACnC,sCAAsC;AAUtC;;;GAGG;AACH,MAAM,UAAU,cAAc,CAAC,MAAc;IAC3C,MAAM,CAAC,IAAI,GAAG,EAAE,EAAE,GAAG,UAAU,CAAC,GAAG,MAAM,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,CAAC,IAAI,EAAE,CAAC,CAAC;IAChF,MAAM,IAAI,GAAG,IAAI,CAAC,KAAK,CAAC,CAAC,EAAE,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC;IAClE,IAAI,IAAI,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QACtB,OAAO,SAAS,CAAC;IACnB,CAAC;IACD,MAAM,OAAO,GAAG,UAAU,CAAC,GAAG,CAAC,CAAC,SAAS,EAAE,EAAE,CAAC,SAAS,CAAC,WAAW,EAAE,CAAC,CAAC;IACvE,MAAM,iBAAiB,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC,SAAS,EAAE,EAAE,CAAC,SAAS,CAAC,UAAU,CAAC,WAAW,CAAC,CAAC,CAAC;IACzF,OAAO;QACL,IAAI;QACJ,UAAU,EAAE,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAC;QACxC,QAAQ,EAAE,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAC;QACpC,QAAQ,EAAE,iBAAiB,EAAE,KAAK,CAAC,WAAW,CAAC,MAAM,CAAC;KACvD,CAAC;AACJ,CAAC;AAED,kGAAkG;AAClG,6EAA6E;AAC7E,MAAM,mBAAmB,GAAG,iCAAiC,CAAC;AAE9D,MAAM,UAAU,sBAAsB,CAAC,IAAY;IACjD,OAAO,mBAAmB,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;AACxC,CAAC"}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { SecurityCheck } from './security-check.js';
|
|
2
|
+
/** Every check this version implements. A class the authorization lists but is absent here is reported `skipped`. */
|
|
3
|
+
export declare const DEFAULT_SECURITY_CHECKS: readonly SecurityCheck[];
|
|
4
|
+
//# sourceMappingURL=default-checks.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"default-checks.d.ts","sourceRoot":"","sources":["../src/default-checks.ts"],"names":[],"mappings":"AAcA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAEzD,qHAAqH;AACrH,eAAO,MAAM,uBAAuB,EAAE,SAAS,aAAa,EAY3D,CAAC"}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Copyright The QA-AI-STLC Authors
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
import { authzCheck } from './checks/authz-check.js';
|
|
4
|
+
import { configurationCheck } from './checks/configuration-check.js';
|
|
5
|
+
import { cookiesCheck } from './checks/cookies-check.js';
|
|
6
|
+
import { corsCheck } from './checks/cors-check.js';
|
|
7
|
+
import { csrfCheck } from './checks/csrf-check.js';
|
|
8
|
+
import { dependenciesCheck } from './checks/dependencies-check.js';
|
|
9
|
+
import { encodingCheck } from './checks/encoding-check.js';
|
|
10
|
+
import { errorsCheck } from './checks/errors-check.js';
|
|
11
|
+
import { headersCheck } from './checks/headers-check.js';
|
|
12
|
+
import { secretsCheck } from './checks/secrets-check.js';
|
|
13
|
+
import { sessionCheck } from './checks/session-check.js';
|
|
14
|
+
/** Every check this version implements. A class the authorization lists but is absent here is reported `skipped`. */
|
|
15
|
+
export const DEFAULT_SECURITY_CHECKS = [
|
|
16
|
+
headersCheck,
|
|
17
|
+
cookiesCheck,
|
|
18
|
+
corsCheck,
|
|
19
|
+
csrfCheck,
|
|
20
|
+
authzCheck,
|
|
21
|
+
sessionCheck,
|
|
22
|
+
errorsCheck,
|
|
23
|
+
encodingCheck,
|
|
24
|
+
dependenciesCheck,
|
|
25
|
+
secretsCheck,
|
|
26
|
+
configurationCheck,
|
|
27
|
+
];
|
|
28
|
+
//# sourceMappingURL=default-checks.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"default-checks.js","sourceRoot":"","sources":["../src/default-checks.ts"],"names":[],"mappings":"AAAA,mCAAmC;AACnC,sCAAsC;AAEtC,OAAO,EAAE,UAAU,EAAE,MAAM,yBAAyB,CAAC;AACrD,OAAO,EAAE,kBAAkB,EAAE,MAAM,iCAAiC,CAAC;AACrE,OAAO,EAAE,YAAY,EAAE,MAAM,2BAA2B,CAAC;AACzD,OAAO,EAAE,SAAS,EAAE,MAAM,wBAAwB,CAAC;AACnD,OAAO,EAAE,SAAS,EAAE,MAAM,wBAAwB,CAAC;AACnD,OAAO,EAAE,iBAAiB,EAAE,MAAM,gCAAgC,CAAC;AACnE,OAAO,EAAE,aAAa,EAAE,MAAM,4BAA4B,CAAC;AAC3D,OAAO,EAAE,WAAW,EAAE,MAAM,0BAA0B,CAAC;AACvD,OAAO,EAAE,YAAY,EAAE,MAAM,2BAA2B,CAAC;AACzD,OAAO,EAAE,YAAY,EAAE,MAAM,2BAA2B,CAAC;AACzD,OAAO,EAAE,YAAY,EAAE,MAAM,2BAA2B,CAAC;AAGzD,qHAAqH;AACrH,MAAM,CAAC,MAAM,uBAAuB,GAA6B;IAC/D,YAAY;IACZ,YAAY;IACZ,SAAS;IACT,SAAS;IACT,UAAU;IACV,YAAY;IACZ,WAAW;IACX,aAAa;IACb,iBAAiB;IACjB,YAAY;IACZ,kBAAkB;CACnB,CAAC"}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { ProbeResponse, SecurityProbe } from './security-probe.js';
|
|
2
|
+
export interface FetchedPage {
|
|
3
|
+
/** The last response reached; for a page that redirected, the page it redirected to. */
|
|
4
|
+
readonly response: ProbeResponse;
|
|
5
|
+
/** The path that produced `response`. */
|
|
6
|
+
readonly path: string;
|
|
7
|
+
/** Every response on the way, first request first. */
|
|
8
|
+
readonly chain: readonly ProbeResponse[];
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* GETs `path`, following a redirect only while it stays on the audited origin, one request at a
|
|
12
|
+
* time through the probe (so each hop meets the same limits as any other request). A redirect
|
|
13
|
+
* to another host is not followed: that response is the page.
|
|
14
|
+
*/
|
|
15
|
+
export declare function fetchPage(probe: SecurityProbe, baseUrl: string, path: string, options?: {
|
|
16
|
+
readonly headers?: Readonly<Record<string, string>>;
|
|
17
|
+
readonly maxHops?: number;
|
|
18
|
+
}): Promise<FetchedPage>;
|
|
19
|
+
//# sourceMappingURL=fetch-page.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"fetch-page.d.ts","sourceRoot":"","sources":["../src/fetch-page.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,aAAa,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAKxE,MAAM,WAAW,WAAW;IAC1B,wFAAwF;IACxF,QAAQ,CAAC,QAAQ,EAAE,aAAa,CAAC;IACjC,yCAAyC;IACzC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,sDAAsD;IACtD,QAAQ,CAAC,KAAK,EAAE,SAAS,aAAa,EAAE,CAAC;CAC1C;AASD;;;;GAIG;AACH,wBAAsB,SAAS,CAC7B,KAAK,EAAE,aAAa,EACpB,OAAO,EAAE,MAAM,EACf,IAAI,EAAE,MAAM,EACZ,OAAO,GAAE;IAAE,QAAQ,CAAC,OAAO,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IAAC,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAA;CAAO,GAC/F,OAAO,CAAC,WAAW,CAAC,CAoBtB"}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Copyright The QA-AI-STLC Authors
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
|
|
4
|
+
const DEFAULT_MAX_HOPS = 3;
|
|
5
|
+
/** The path and query of a redirect target when it stays on the audited origin, else `undefined`. */
|
|
6
|
+
function sameOriginTarget(location, currentPath, baseUrl) {
|
|
7
|
+
const base = new URL(baseUrl);
|
|
8
|
+
const target = new URL(location, new URL(currentPath, base));
|
|
9
|
+
return target.origin === base.origin ? `${target.pathname}${target.search}` : undefined;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* GETs `path`, following a redirect only while it stays on the audited origin, one request at a
|
|
13
|
+
* time through the probe (so each hop meets the same limits as any other request). A redirect
|
|
14
|
+
* to another host is not followed: that response is the page.
|
|
15
|
+
*/
|
|
16
|
+
export async function fetchPage(probe, baseUrl, path, options = {}) {
|
|
17
|
+
const chain = [];
|
|
18
|
+
let currentPath = path;
|
|
19
|
+
for (let hop = 0;; hop += 1) {
|
|
20
|
+
const response = await probe.request({
|
|
21
|
+
method: 'GET',
|
|
22
|
+
path: currentPath,
|
|
23
|
+
...(options.headers !== undefined ? { headers: options.headers } : {}),
|
|
24
|
+
});
|
|
25
|
+
chain.push(response);
|
|
26
|
+
const location = response.headers.location;
|
|
27
|
+
const next = REDIRECT_STATUSES.has(response.status) && location !== undefined
|
|
28
|
+
? sameOriginTarget(location, currentPath, baseUrl)
|
|
29
|
+
: undefined;
|
|
30
|
+
if (next === undefined || hop >= (options.maxHops ?? DEFAULT_MAX_HOPS)) {
|
|
31
|
+
return { response, path: currentPath, chain };
|
|
32
|
+
}
|
|
33
|
+
currentPath = next;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
//# sourceMappingURL=fetch-page.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"fetch-page.js","sourceRoot":"","sources":["../src/fetch-page.ts"],"names":[],"mappings":"AAAA,mCAAmC;AACnC,sCAAsC;AAItC,MAAM,iBAAiB,GAAwB,IAAI,GAAG,CAAC,CAAC,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,CAAC,CAAC,CAAC;AAClF,MAAM,gBAAgB,GAAG,CAAC,CAAC;AAW3B,qGAAqG;AACrG,SAAS,gBAAgB,CAAC,QAAgB,EAAE,WAAmB,EAAE,OAAe;IAC9E,MAAM,IAAI,GAAG,IAAI,GAAG,CAAC,OAAO,CAAC,CAAC;IAC9B,MAAM,MAAM,GAAG,IAAI,GAAG,CAAC,QAAQ,EAAE,IAAI,GAAG,CAAC,WAAW,EAAE,IAAI,CAAC,CAAC,CAAC;IAC7D,OAAO,MAAM,CAAC,MAAM,KAAK,IAAI,CAAC,MAAM,CAAC,CAAC,CAAC,GAAG,MAAM,CAAC,QAAQ,GAAG,MAAM,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC,SAAS,CAAC;AAC1F,CAAC;AAED;;;;GAIG;AACH,MAAM,CAAC,KAAK,UAAU,SAAS,CAC7B,KAAoB,EACpB,OAAe,EACf,IAAY,EACZ,UAA8F,EAAE;IAEhG,MAAM,KAAK,GAAoB,EAAE,CAAC;IAClC,IAAI,WAAW,GAAG,IAAI,CAAC;IACvB,KAAK,IAAI,GAAG,GAAG,CAAC,GAAI,GAAG,IAAI,CAAC,EAAE,CAAC;QAC7B,MAAM,QAAQ,GAAG,MAAM,KAAK,CAAC,OAAO,CAAC;YACnC,MAAM,EAAE,KAAK;YACb,IAAI,EAAE,WAAW;YACjB,GAAG,CAAC,OAAO,CAAC,OAAO,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,OAAO,EAAE,OAAO,CAAC,OAAO,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;SACvE,CAAC,CAAC;QACH,KAAK,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC;QACrB,MAAM,QAAQ,GAAG,QAAQ,CAAC,OAAO,CAAC,QAAQ,CAAC;QAC3C,MAAM,IAAI,GACR,iBAAiB,CAAC,GAAG,CAAC,QAAQ,CAAC,MAAM,CAAC,IAAI,QAAQ,KAAK,SAAS;YAC9D,CAAC,CAAC,gBAAgB,CAAC,QAAQ,EAAE,WAAW,EAAE,OAAO,CAAC;YAClD,CAAC,CAAC,SAAS,CAAC;QAChB,IAAI,IAAI,KAAK,SAAS,IAAI,GAAG,IAAI,CAAC,OAAO,CAAC,OAAO,IAAI,gBAAgB,CAAC,EAAE,CAAC;YACvE,OAAO,EAAE,QAAQ,EAAE,IAAI,EAAE,WAAW,EAAE,KAAK,EAAE,CAAC;QAChD,CAAC;QACD,WAAW,GAAG,IAAI,CAAC;IACrB,CAAC;AACH,CAAC"}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { SecurityFinding } from '@qa-ai-stlc/schemas';
|
|
2
|
+
/** A finding id is part of a draft's id and file name, so it is reduced to a plain slug. */
|
|
3
|
+
export declare function findingId(...parts: readonly string[]): string;
|
|
4
|
+
export type FindingInput = Omit<SecurityFinding, 'id'> & {
|
|
5
|
+
readonly idParts: readonly string[];
|
|
6
|
+
};
|
|
7
|
+
export declare function buildFinding({ idParts, ...rest }: FindingInput): SecurityFinding;
|
|
8
|
+
//# sourceMappingURL=finding.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"finding.d.ts","sourceRoot":"","sources":["../src/finding.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,qBAAqB,CAAC;AAE3D,4FAA4F;AAC5F,wBAAgB,SAAS,CAAC,GAAG,KAAK,EAAE,SAAS,MAAM,EAAE,GAAG,MAAM,CAM7D;AAED,MAAM,MAAM,YAAY,GAAG,IAAI,CAAC,eAAe,EAAE,IAAI,CAAC,GAAG;IAAE,QAAQ,CAAC,OAAO,EAAE,SAAS,MAAM,EAAE,CAAA;CAAE,CAAC;AAEjG,wBAAgB,YAAY,CAAC,EAAE,OAAO,EAAE,GAAG,IAAI,EAAE,EAAE,YAAY,GAAG,eAAe,CAEhF"}
|
package/dist/finding.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Copyright The QA-AI-STLC Authors
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
/** A finding id is part of a draft's id and file name, so it is reduced to a plain slug. */
|
|
4
|
+
export function findingId(...parts) {
|
|
5
|
+
return parts
|
|
6
|
+
.join('-')
|
|
7
|
+
.toLowerCase()
|
|
8
|
+
.replace(/[^a-z0-9]+/gu, '-')
|
|
9
|
+
.replace(/^-+|-+$/gu, '');
|
|
10
|
+
}
|
|
11
|
+
export function buildFinding({ idParts, ...rest }) {
|
|
12
|
+
return { id: findingId(...idParts), ...rest };
|
|
13
|
+
}
|
|
14
|
+
//# sourceMappingURL=finding.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"finding.js","sourceRoot":"","sources":["../src/finding.ts"],"names":[],"mappings":"AAAA,mCAAmC;AACnC,sCAAsC;AAItC,4FAA4F;AAC5F,MAAM,UAAU,SAAS,CAAC,GAAG,KAAwB;IACnD,OAAO,KAAK;SACT,IAAI,CAAC,GAAG,CAAC;SACT,WAAW,EAAE;SACb,OAAO,CAAC,cAAc,EAAE,GAAG,CAAC;SAC5B,OAAO,CAAC,WAAW,EAAE,EAAE,CAAC,CAAC;AAC9B,CAAC;AAID,MAAM,UAAU,YAAY,CAAC,EAAE,OAAO,EAAE,GAAG,IAAI,EAAgB;IAC7D,OAAO,EAAE,EAAE,EAAE,SAAS,CAAC,GAAG,OAAO,CAAC,EAAE,GAAG,IAAI,EAAE,CAAC;AAChD,CAAC"}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { type EngineContext, type StorageState } from '@qa-ai-stlc/core';
|
|
2
|
+
import type { SecurityAuthorization } from '@qa-ai-stlc/schemas';
|
|
3
|
+
import type { CookieFlags } from './cookie-flags.js';
|
|
4
|
+
type Role = SecurityAuthorization['identities'][number]['role'];
|
|
5
|
+
/** One identity's signed-in browser session, reduced to what the audit's HTTP requests need. */
|
|
6
|
+
export interface IdentitySession {
|
|
7
|
+
readonly name: string;
|
|
8
|
+
readonly role: Role;
|
|
9
|
+
/** Every cookie of the session that applies to the audited host, with its protective flags. */
|
|
10
|
+
readonly cookies: readonly CookieFlags[];
|
|
11
|
+
/** The `Cookie` header that replays the session. It holds credentials: never log or record it. */
|
|
12
|
+
readonly cookieHeader: string;
|
|
13
|
+
}
|
|
14
|
+
/** The identities the authorization names, signed in, plus the reason any of them could not be. */
|
|
15
|
+
export declare class IdentitySessions {
|
|
16
|
+
private readonly sessions;
|
|
17
|
+
private readonly failures;
|
|
18
|
+
constructor(sessions: ReadonlyMap<string, IdentitySession>, failures: ReadonlyMap<string, string>);
|
|
19
|
+
all(): readonly IdentitySession[];
|
|
20
|
+
/** The first signed-in identity with `role`. */
|
|
21
|
+
byRole(role: Role): IdentitySession | undefined;
|
|
22
|
+
/** Why identities are missing, for a check that cannot run without them. */
|
|
23
|
+
describeFailures(): string | undefined;
|
|
24
|
+
}
|
|
25
|
+
export declare const NO_IDENTITY_SESSIONS: IdentitySessions;
|
|
26
|
+
/** Reduces a storage state to the session the audit replays; exported for tests. */
|
|
27
|
+
export declare function toIdentitySession(name: string, role: Role, storageState: StorageState, baseUrl: string, nowMs: number): IdentitySession;
|
|
28
|
+
export interface OpenIdentitySessionsOptions {
|
|
29
|
+
/** Endpoints of Chrome instances the operator is already signed into, by identity name (`cdp-attach` identities). */
|
|
30
|
+
readonly cdpEndpointUrls?: Readonly<Record<string, string>>;
|
|
31
|
+
/** Supplies a storage state instead of signing in; for tests. */
|
|
32
|
+
readonly storageStateFor?: (name: string) => Promise<StorageState | undefined>;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Signs in the identities the authorization names. A saved storage state is used when there is
|
|
36
|
+
* one; otherwise the identity signs in through the same `authenticate()` the explorer uses, and a
|
|
37
|
+
* scripted login whose page is outside the authorized origin is refused before the browser starts.
|
|
38
|
+
* An identity that cannot sign in is reported, not fatal: the checks that need it say `blocked`.
|
|
39
|
+
*/
|
|
40
|
+
export declare function openIdentitySessions(context: EngineContext, authorization: SecurityAuthorization, options?: OpenIdentitySessionsOptions): Promise<IdentitySessions>;
|
|
41
|
+
export {};
|
|
42
|
+
//# sourceMappingURL=identity-sessions.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"identity-sessions.d.ts","sourceRoot":"","sources":["../src/identity-sessions.ts"],"names":[],"mappings":"AAGA,OAAO,EAOL,KAAK,aAAa,EAClB,KAAK,YAAY,EAClB,MAAM,kBAAkB,CAAC;AAC1B,OAAO,KAAK,EAAkB,qBAAqB,EAAE,MAAM,qBAAqB,CAAC;AACjF,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAErD,KAAK,IAAI,GAAG,qBAAqB,CAAC,YAAY,CAAC,CAAC,MAAM,CAAC,CAAC,MAAM,CAAC,CAAC;AAGhE,gGAAgG;AAChG,MAAM,WAAW,eAAe;IAC9B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,IAAI,EAAE,IAAI,CAAC;IACpB,+FAA+F;IAC/F,QAAQ,CAAC,OAAO,EAAE,SAAS,WAAW,EAAE,CAAC;IACzC,kGAAkG;IAClG,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;CAC/B;AAED,mGAAmG;AACnG,qBAAa,gBAAgB;IAEzB,OAAO,CAAC,QAAQ,CAAC,QAAQ;IACzB,OAAO,CAAC,QAAQ,CAAC,QAAQ;gBADR,QAAQ,EAAE,WAAW,CAAC,MAAM,EAAE,eAAe,CAAC,EAC9C,QAAQ,EAAE,WAAW,CAAC,MAAM,EAAE,MAAM,CAAC;IAGxD,GAAG,IAAI,SAAS,eAAe,EAAE;IAIjC,gDAAgD;IAChD,MAAM,CAAC,IAAI,EAAE,IAAI,GAAG,eAAe,GAAG,SAAS;IAI/C,4EAA4E;IAC5E,gBAAgB,IAAI,MAAM,GAAG,SAAS;CAMvC;AAED,eAAO,MAAM,oBAAoB,kBAA6C,CAAC;AAqB/E,oFAAoF;AACpF,wBAAgB,iBAAiB,CAC/B,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE,IAAI,EACV,YAAY,EAAE,YAAY,EAC1B,OAAO,EAAE,MAAM,EACf,KAAK,EAAE,MAAM,GACZ,eAAe,CAWjB;AAED,MAAM,WAAW,2BAA2B;IAC1C,qHAAqH;IACrH,QAAQ,CAAC,eAAe,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IAC5D,iEAAiE;IACjE,QAAQ,CAAC,eAAe,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,YAAY,GAAG,SAAS,CAAC,CAAC;CAChF;AAED;;;;;GAKG;AACH,wBAAsB,oBAAoB,CACxC,OAAO,EAAE,aAAa,EACtB,aAAa,EAAE,qBAAqB,EACpC,OAAO,GAAE,2BAAgC,GACxC,OAAO,CAAC,gBAAgB,CAAC,CAuC3B"}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// Copyright The QA-AI-STLC Authors
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
import { AuthSessionStore, QaError, QaStore, authenticate, isUrlAllowed, loadConfig, } from '@qa-ai-stlc/core';
|
|
4
|
+
/** The identities the authorization names, signed in, plus the reason any of them could not be. */
|
|
5
|
+
export class IdentitySessions {
|
|
6
|
+
sessions;
|
|
7
|
+
failures;
|
|
8
|
+
constructor(sessions, failures) {
|
|
9
|
+
this.sessions = sessions;
|
|
10
|
+
this.failures = failures;
|
|
11
|
+
}
|
|
12
|
+
all() {
|
|
13
|
+
return [...this.sessions.values()];
|
|
14
|
+
}
|
|
15
|
+
/** The first signed-in identity with `role`. */
|
|
16
|
+
byRole(role) {
|
|
17
|
+
return this.all().find((session) => session.role === role);
|
|
18
|
+
}
|
|
19
|
+
/** Why identities are missing, for a check that cannot run without them. */
|
|
20
|
+
describeFailures() {
|
|
21
|
+
if (this.failures.size === 0) {
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
return [...this.failures].map(([name, reason]) => `${name}: ${reason}`).join('; ');
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
export const NO_IDENTITY_SESSIONS = new IdentitySessions(new Map(), new Map());
|
|
28
|
+
function isForHost(cookie, hostname) {
|
|
29
|
+
const domain = cookie.domain.replace(/^\./u, '');
|
|
30
|
+
return hostname === domain || hostname.endsWith(`.${domain}`);
|
|
31
|
+
}
|
|
32
|
+
function isLive(cookie, nowMs) {
|
|
33
|
+
// Playwright marks a session cookie with an expiry of -1.
|
|
34
|
+
return cookie.expires === -1 || cookie.expires * 1000 > nowMs;
|
|
35
|
+
}
|
|
36
|
+
function toFlags(cookie) {
|
|
37
|
+
return {
|
|
38
|
+
name: cookie.name,
|
|
39
|
+
isHttpOnly: cookie.httpOnly,
|
|
40
|
+
isSecure: cookie.secure,
|
|
41
|
+
sameSite: cookie.sameSite.toLowerCase(),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
/** Reduces a storage state to the session the audit replays; exported for tests. */
|
|
45
|
+
export function toIdentitySession(name, role, storageState, baseUrl, nowMs) {
|
|
46
|
+
const { hostname } = new URL(baseUrl);
|
|
47
|
+
const applicable = storageState.cookies.filter((cookie) => isForHost(cookie, hostname) && isLive(cookie, nowMs));
|
|
48
|
+
return {
|
|
49
|
+
name,
|
|
50
|
+
role,
|
|
51
|
+
cookies: applicable.map(toFlags),
|
|
52
|
+
cookieHeader: applicable.map((cookie) => `${cookie.name}=${cookie.value}`).join('; '),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Signs in the identities the authorization names. A saved storage state is used when there is
|
|
57
|
+
* one; otherwise the identity signs in through the same `authenticate()` the explorer uses, and a
|
|
58
|
+
* scripted login whose page is outside the authorized origin is refused before the browser starts.
|
|
59
|
+
* An identity that cannot sign in is reported, not fatal: the checks that need it say `blocked`.
|
|
60
|
+
*/
|
|
61
|
+
export async function openIdentitySessions(context, authorization, options = {}) {
|
|
62
|
+
const config = await loadConfig(context);
|
|
63
|
+
const environment = config.environments[authorization.environment.name];
|
|
64
|
+
const store = new QaStore({ projectRoot: context.projectRoot, fs: context.fs });
|
|
65
|
+
const savedSessions = new AuthSessionStore(store);
|
|
66
|
+
const sessions = new Map();
|
|
67
|
+
const failures = new Map();
|
|
68
|
+
for (const { name, role } of authorization.identities) {
|
|
69
|
+
const identity = config.identities[name];
|
|
70
|
+
try {
|
|
71
|
+
if (identity === undefined) {
|
|
72
|
+
throw new QaError('SECURITY_IDENTITY_UNKNOWN', `"${name}" is not a configured identity`);
|
|
73
|
+
}
|
|
74
|
+
const storageState = (await (options.storageStateFor ?? ((identityName) => savedSessions.load(identityName)))(name)) ??
|
|
75
|
+
(await signIn(context, authorization, name, identity, options, environment?.tlsInsecure === true));
|
|
76
|
+
sessions.set(name, toIdentitySession(name, role, storageState, authorization.environment.baseUrl, context.clock.now().getTime()));
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
if (!(error instanceof QaError)) {
|
|
80
|
+
throw error;
|
|
81
|
+
}
|
|
82
|
+
context.logger.error('A security audit identity could not sign in', {
|
|
83
|
+
code: error.code,
|
|
84
|
+
identity: name,
|
|
85
|
+
});
|
|
86
|
+
failures.set(name, error.message);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return new IdentitySessions(sessions, failures);
|
|
90
|
+
}
|
|
91
|
+
async function signIn(context, authorization, name, identity, options, tlsInsecure) {
|
|
92
|
+
const { baseUrl, allowlist } = authorization.environment;
|
|
93
|
+
if (identity.loginUrl !== undefined && !isUrlAllowed(identity.loginUrl, allowlist, baseUrl)) {
|
|
94
|
+
throw new QaError('SECURITY_IDENTITY_LOGIN_OUTSIDE_ALLOWLIST', `The sign-in page of "${name}" is outside the authorized origin`, { remediation: 'Authorize the environment the sign-in page belongs to, or sign in with cdp-attach.' });
|
|
95
|
+
}
|
|
96
|
+
const cdpEndpointUrl = options.cdpEndpointUrls?.[name];
|
|
97
|
+
return authenticate(context.browserLauncher, identity, context.env, {
|
|
98
|
+
...(cdpEndpointUrl !== undefined ? { cdpEndpointUrl } : {}),
|
|
99
|
+
...(tlsInsecure ? { tlsInsecure: true } : {}),
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
//# sourceMappingURL=identity-sessions.js.map
|